posthawk 0.1.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.
- posthawk-0.1.0/.gitignore +51 -0
- posthawk-0.1.0/PKG-INFO +172 -0
- posthawk-0.1.0/README.md +147 -0
- posthawk-0.1.0/pyproject.toml +35 -0
- posthawk-0.1.0/src/posthawk/__init__.py +29 -0
- posthawk-0.1.0/src/posthawk/_http.py +70 -0
- posthawk-0.1.0/src/posthawk/_version.py +1 -0
- posthawk-0.1.0/src/posthawk/client.py +55 -0
- posthawk-0.1.0/src/posthawk/emails.py +98 -0
- posthawk-0.1.0/src/posthawk/error.py +16 -0
- posthawk-0.1.0/src/posthawk/scheduled.py +114 -0
- posthawk-0.1.0/src/posthawk/types.py +90 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnp
|
|
4
|
+
.pnp.js
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
coverage/
|
|
8
|
+
|
|
9
|
+
# Next.js
|
|
10
|
+
.next/
|
|
11
|
+
out/
|
|
12
|
+
|
|
13
|
+
# Production
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
|
|
17
|
+
# Misc
|
|
18
|
+
.DS_Store
|
|
19
|
+
*.pem
|
|
20
|
+
.env
|
|
21
|
+
.env.local
|
|
22
|
+
.env.*.local
|
|
23
|
+
|
|
24
|
+
# Debug
|
|
25
|
+
npm-debug.log*
|
|
26
|
+
yarn-debug.log*
|
|
27
|
+
yarn-error.log*
|
|
28
|
+
pnpm-debug.log*
|
|
29
|
+
|
|
30
|
+
# Turbo
|
|
31
|
+
.turbo/
|
|
32
|
+
|
|
33
|
+
# Supabase
|
|
34
|
+
.supabase/
|
|
35
|
+
|
|
36
|
+
# Wrangler (Cloudflare)
|
|
37
|
+
.wrangler/
|
|
38
|
+
|
|
39
|
+
# IDE
|
|
40
|
+
.vscode/
|
|
41
|
+
.idea/
|
|
42
|
+
*.swp
|
|
43
|
+
*.swo
|
|
44
|
+
*~
|
|
45
|
+
|
|
46
|
+
# Logs
|
|
47
|
+
*.log
|
|
48
|
+
/logs/
|
|
49
|
+
|
|
50
|
+
# OS
|
|
51
|
+
Thumbs.db
|
posthawk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: posthawk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Posthawk SDK for sending emails
|
|
5
|
+
Project-URL: Homepage, https://posthawk.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.posthawk.dev/sdk-python
|
|
7
|
+
Project-URL: Repository, https://github.com/endibuka/posthawk-python
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: email,posthawk,sdk,transactional-email
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Requires-Dist: httpx>=0.24.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Posthawk Python SDK
|
|
27
|
+
|
|
28
|
+
The official Python SDK for [Posthawk](https://posthawk.dev) — send transactional emails, schedule deliveries, and manage email jobs.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install posthawk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from posthawk import Posthawk
|
|
40
|
+
|
|
41
|
+
client = Posthawk("ck_live_...")
|
|
42
|
+
|
|
43
|
+
result = client.emails.send(
|
|
44
|
+
from_email="hi@yourdomain.com",
|
|
45
|
+
to="user@example.com",
|
|
46
|
+
subject="Hello from Posthawk",
|
|
47
|
+
html="<h1>Welcome!</h1><p>Your account is ready.</p>",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if result.error:
|
|
51
|
+
print(result.error.message)
|
|
52
|
+
else:
|
|
53
|
+
print(f"Sent! Job ID: {result.data.job_id}")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Send Email
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
result = client.emails.send(
|
|
60
|
+
from_email="hi@yourdomain.com",
|
|
61
|
+
to=["alice@example.com", "bob@example.com"],
|
|
62
|
+
cc="manager@example.com",
|
|
63
|
+
subject="Weekly Report",
|
|
64
|
+
html="<h1>Report</h1>",
|
|
65
|
+
text="Plain text fallback",
|
|
66
|
+
headers={"X-Custom": "value"},
|
|
67
|
+
metadata={"campaign": "onboarding"},
|
|
68
|
+
tags={"type": "transactional"},
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Schedule Emails
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from datetime import datetime, timedelta, timezone
|
|
76
|
+
|
|
77
|
+
result = client.emails.send(
|
|
78
|
+
from_email="hi@yourdomain.com",
|
|
79
|
+
to="user@example.com",
|
|
80
|
+
subject="Reminder",
|
|
81
|
+
text="Don't forget your appointment tomorrow!",
|
|
82
|
+
scheduled_for=datetime.now(timezone.utc) + timedelta(hours=24),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
print(f"Scheduled for: {result.data.scheduled_for}")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Check Delivery Status
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
result = client.emails.get("job-id-here")
|
|
92
|
+
|
|
93
|
+
if not result.error:
|
|
94
|
+
print(f"Status: {result.data.status}")
|
|
95
|
+
# pending | processing | completed | failed
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Manage Scheduled Emails
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# List all scheduled emails
|
|
102
|
+
result = client.scheduled.list(status="scheduled", limit=10)
|
|
103
|
+
for email in result.data.data:
|
|
104
|
+
print(f"{email.subject} → {email.scheduled_for}")
|
|
105
|
+
|
|
106
|
+
# Get a specific scheduled email
|
|
107
|
+
result = client.scheduled.get("scheduled-email-id")
|
|
108
|
+
|
|
109
|
+
# Cancel before it sends
|
|
110
|
+
result = client.scheduled.cancel("scheduled-email-id")
|
|
111
|
+
|
|
112
|
+
# Reschedule to a new time
|
|
113
|
+
result = client.scheduled.reschedule(
|
|
114
|
+
"scheduled-email-id",
|
|
115
|
+
scheduled_for="2026-04-01T10:00:00Z",
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Self-Hosted
|
|
120
|
+
|
|
121
|
+
Point the SDK at your own Posthawk instance:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
client = Posthawk("ck_live_...", base_url="https://api.yourdomain.com")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Error Handling
|
|
128
|
+
|
|
129
|
+
SDK methods never raise exceptions for API errors. Every method returns a `PosthawkResponse` with `.data` and `.error`:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
result = client.emails.send(
|
|
133
|
+
from_email="hi@yourdomain.com",
|
|
134
|
+
to="user@example.com",
|
|
135
|
+
subject="Test",
|
|
136
|
+
html="<p>Hello</p>",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if result.error:
|
|
140
|
+
print(f"Error {result.error.status_code}: {result.error.message}")
|
|
141
|
+
else:
|
|
142
|
+
print(f"Success: {result.data.job_id}")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Context Manager
|
|
146
|
+
|
|
147
|
+
Use a context manager to automatically close the HTTP connection pool:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
with Posthawk("ck_live_...") as client:
|
|
151
|
+
result = client.emails.send(
|
|
152
|
+
from_email="hi@yourdomain.com",
|
|
153
|
+
to="user@example.com",
|
|
154
|
+
subject="Hello",
|
|
155
|
+
html="<h1>Hi</h1>",
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## API Reference
|
|
160
|
+
|
|
161
|
+
| Method | Description |
|
|
162
|
+
|--------|-------------|
|
|
163
|
+
| `client.emails.send(...)` | Send an email or schedule one |
|
|
164
|
+
| `client.emails.get(job_id)` | Check delivery status |
|
|
165
|
+
| `client.scheduled.list(...)` | List scheduled emails |
|
|
166
|
+
| `client.scheduled.get(id)` | Get a scheduled email |
|
|
167
|
+
| `client.scheduled.cancel(id)` | Cancel a scheduled email |
|
|
168
|
+
| `client.scheduled.reschedule(id, ...)` | Reschedule an email |
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
posthawk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Posthawk Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python SDK for [Posthawk](https://posthawk.dev) — send transactional emails, schedule deliveries, and manage email jobs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install posthawk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from posthawk import Posthawk
|
|
15
|
+
|
|
16
|
+
client = Posthawk("ck_live_...")
|
|
17
|
+
|
|
18
|
+
result = client.emails.send(
|
|
19
|
+
from_email="hi@yourdomain.com",
|
|
20
|
+
to="user@example.com",
|
|
21
|
+
subject="Hello from Posthawk",
|
|
22
|
+
html="<h1>Welcome!</h1><p>Your account is ready.</p>",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if result.error:
|
|
26
|
+
print(result.error.message)
|
|
27
|
+
else:
|
|
28
|
+
print(f"Sent! Job ID: {result.data.job_id}")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Send Email
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
result = client.emails.send(
|
|
35
|
+
from_email="hi@yourdomain.com",
|
|
36
|
+
to=["alice@example.com", "bob@example.com"],
|
|
37
|
+
cc="manager@example.com",
|
|
38
|
+
subject="Weekly Report",
|
|
39
|
+
html="<h1>Report</h1>",
|
|
40
|
+
text="Plain text fallback",
|
|
41
|
+
headers={"X-Custom": "value"},
|
|
42
|
+
metadata={"campaign": "onboarding"},
|
|
43
|
+
tags={"type": "transactional"},
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Schedule Emails
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from datetime import datetime, timedelta, timezone
|
|
51
|
+
|
|
52
|
+
result = client.emails.send(
|
|
53
|
+
from_email="hi@yourdomain.com",
|
|
54
|
+
to="user@example.com",
|
|
55
|
+
subject="Reminder",
|
|
56
|
+
text="Don't forget your appointment tomorrow!",
|
|
57
|
+
scheduled_for=datetime.now(timezone.utc) + timedelta(hours=24),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(f"Scheduled for: {result.data.scheduled_for}")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Check Delivery Status
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
result = client.emails.get("job-id-here")
|
|
67
|
+
|
|
68
|
+
if not result.error:
|
|
69
|
+
print(f"Status: {result.data.status}")
|
|
70
|
+
# pending | processing | completed | failed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Manage Scheduled Emails
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# List all scheduled emails
|
|
77
|
+
result = client.scheduled.list(status="scheduled", limit=10)
|
|
78
|
+
for email in result.data.data:
|
|
79
|
+
print(f"{email.subject} → {email.scheduled_for}")
|
|
80
|
+
|
|
81
|
+
# Get a specific scheduled email
|
|
82
|
+
result = client.scheduled.get("scheduled-email-id")
|
|
83
|
+
|
|
84
|
+
# Cancel before it sends
|
|
85
|
+
result = client.scheduled.cancel("scheduled-email-id")
|
|
86
|
+
|
|
87
|
+
# Reschedule to a new time
|
|
88
|
+
result = client.scheduled.reschedule(
|
|
89
|
+
"scheduled-email-id",
|
|
90
|
+
scheduled_for="2026-04-01T10:00:00Z",
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Self-Hosted
|
|
95
|
+
|
|
96
|
+
Point the SDK at your own Posthawk instance:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = Posthawk("ck_live_...", base_url="https://api.yourdomain.com")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Error Handling
|
|
103
|
+
|
|
104
|
+
SDK methods never raise exceptions for API errors. Every method returns a `PosthawkResponse` with `.data` and `.error`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
result = client.emails.send(
|
|
108
|
+
from_email="hi@yourdomain.com",
|
|
109
|
+
to="user@example.com",
|
|
110
|
+
subject="Test",
|
|
111
|
+
html="<p>Hello</p>",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if result.error:
|
|
115
|
+
print(f"Error {result.error.status_code}: {result.error.message}")
|
|
116
|
+
else:
|
|
117
|
+
print(f"Success: {result.data.job_id}")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Context Manager
|
|
121
|
+
|
|
122
|
+
Use a context manager to automatically close the HTTP connection pool:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
with Posthawk("ck_live_...") as client:
|
|
126
|
+
result = client.emails.send(
|
|
127
|
+
from_email="hi@yourdomain.com",
|
|
128
|
+
to="user@example.com",
|
|
129
|
+
subject="Hello",
|
|
130
|
+
html="<h1>Hi</h1>",
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## API Reference
|
|
135
|
+
|
|
136
|
+
| Method | Description |
|
|
137
|
+
|--------|-------------|
|
|
138
|
+
| `client.emails.send(...)` | Send an email or schedule one |
|
|
139
|
+
| `client.emails.get(job_id)` | Check delivery status |
|
|
140
|
+
| `client.scheduled.list(...)` | List scheduled emails |
|
|
141
|
+
| `client.scheduled.get(id)` | Get a scheduled email |
|
|
142
|
+
| `client.scheduled.cancel(id)` | Cancel a scheduled email |
|
|
143
|
+
| `client.scheduled.reschedule(id, ...)` | Reschedule an email |
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "posthawk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Posthawk SDK for sending emails"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = ["httpx>=0.24.0"]
|
|
13
|
+
keywords = ["email", "posthawk", "sdk", "transactional-email"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Communications :: Email",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://posthawk.dev"
|
|
31
|
+
Documentation = "https://docs.posthawk.dev/sdk-python"
|
|
32
|
+
Repository = "https://github.com/endibuka/posthawk-python"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/posthawk"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import Posthawk
|
|
3
|
+
from .error import PosthawkError
|
|
4
|
+
from .types import (
|
|
5
|
+
CancelResponse,
|
|
6
|
+
EmailJobResult,
|
|
7
|
+
EmailJobStatus,
|
|
8
|
+
PosthawkResponse,
|
|
9
|
+
RescheduleResponse,
|
|
10
|
+
ScheduledEmail,
|
|
11
|
+
ScheduledGetResponse,
|
|
12
|
+
ScheduledListResponse,
|
|
13
|
+
SendEmailResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"CancelResponse",
|
|
19
|
+
"EmailJobResult",
|
|
20
|
+
"EmailJobStatus",
|
|
21
|
+
"Posthawk",
|
|
22
|
+
"PosthawkError",
|
|
23
|
+
"PosthawkResponse",
|
|
24
|
+
"RescheduleResponse",
|
|
25
|
+
"ScheduledEmail",
|
|
26
|
+
"ScheduledGetResponse",
|
|
27
|
+
"ScheduledListResponse",
|
|
28
|
+
"SendEmailResponse",
|
|
29
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .error import PosthawkError
|
|
10
|
+
from .types import PosthawkResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _camel_to_snake(name: str) -> str:
|
|
14
|
+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
15
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _convert_keys(obj: Any) -> Any:
|
|
19
|
+
"""Recursively convert dict keys from camelCase to snake_case."""
|
|
20
|
+
if isinstance(obj, dict):
|
|
21
|
+
return {_camel_to_snake(k): _convert_keys(v) for k, v in obj.items()}
|
|
22
|
+
if isinstance(obj, list):
|
|
23
|
+
return [_convert_keys(item) for item in obj]
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient:
|
|
28
|
+
def __init__(self, base_url: str, api_key: str) -> None:
|
|
29
|
+
self._client = httpx.Client(
|
|
30
|
+
base_url=base_url,
|
|
31
|
+
headers={
|
|
32
|
+
"x-api-key": api_key,
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"User-Agent": f"posthawk-python/{__version__}",
|
|
35
|
+
},
|
|
36
|
+
timeout=30.0,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def request(
|
|
40
|
+
self,
|
|
41
|
+
method: str,
|
|
42
|
+
path: str,
|
|
43
|
+
json: Optional[Dict[str, Any]] = None,
|
|
44
|
+
params: Optional[Dict[str, Any]] = None,
|
|
45
|
+
) -> PosthawkResponse[Any]:
|
|
46
|
+
try:
|
|
47
|
+
response = self._client.request(method, path, json=json, params=params)
|
|
48
|
+
|
|
49
|
+
data = response.json() if response.content else None
|
|
50
|
+
|
|
51
|
+
if not response.is_success:
|
|
52
|
+
message = f"Request failed with status {response.status_code}"
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
message = data.get("message") or data.get("error") or message
|
|
55
|
+
return PosthawkResponse(
|
|
56
|
+
data=None,
|
|
57
|
+
error=PosthawkError(message, response.status_code),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
converted = _convert_keys(data) if data is not None else data
|
|
61
|
+
return PosthawkResponse(data=converted, error=None)
|
|
62
|
+
|
|
63
|
+
except httpx.HTTPError as exc:
|
|
64
|
+
return PosthawkResponse(
|
|
65
|
+
data=None,
|
|
66
|
+
error=PosthawkError(str(exc), 0),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
self._client.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ._http import HttpClient
|
|
6
|
+
from .emails import Emails
|
|
7
|
+
from .error import PosthawkError
|
|
8
|
+
from .scheduled import Scheduled
|
|
9
|
+
|
|
10
|
+
_DEFAULT_BASE_URL = "https://api.posthawk.dev"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Posthawk:
|
|
14
|
+
"""Posthawk SDK client.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
client = Posthawk("ck_live_...")
|
|
19
|
+
|
|
20
|
+
result = client.emails.send(
|
|
21
|
+
from_email="hi@example.com",
|
|
22
|
+
to="user@example.com",
|
|
23
|
+
subject="Hello",
|
|
24
|
+
html="<h1>Hi!</h1>",
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
*,
|
|
32
|
+
base_url: Optional[str] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
if not api_key:
|
|
35
|
+
raise PosthawkError(
|
|
36
|
+
'Missing API key. Pass your key as the first argument: '
|
|
37
|
+
'Posthawk("ck_live_...")',
|
|
38
|
+
401,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
resolved_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
|
|
42
|
+
|
|
43
|
+
self._http = HttpClient(resolved_url, api_key)
|
|
44
|
+
self.emails = Emails(self._http)
|
|
45
|
+
self.scheduled = Scheduled(self._http)
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
"""Close the underlying HTTP client."""
|
|
49
|
+
self._http.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> Posthawk:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *args: object) -> None:
|
|
55
|
+
self.close()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
from .types import (
|
|
9
|
+
EmailJobResult,
|
|
10
|
+
EmailJobStatus,
|
|
11
|
+
PosthawkResponse,
|
|
12
|
+
SendEmailResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_list(value: Optional[Union[str, List[str]]]) -> Optional[List[str]]:
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
return value if isinstance(value, list) else [value]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _to_iso(value: Optional[Union[str, datetime]]) -> Optional[str]:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
return value.isoformat() if isinstance(value, datetime) else value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Emails:
|
|
29
|
+
"""Email sending and status checking."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, http: HttpClient) -> None:
|
|
32
|
+
self._http = http
|
|
33
|
+
|
|
34
|
+
def send(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
from_email: str,
|
|
38
|
+
to: Union[str, List[str]],
|
|
39
|
+
subject: str,
|
|
40
|
+
cc: Optional[Union[str, List[str]]] = None,
|
|
41
|
+
bcc: Optional[Union[str, List[str]]] = None,
|
|
42
|
+
html: Optional[str] = None,
|
|
43
|
+
text: Optional[str] = None,
|
|
44
|
+
template_id: Optional[str] = None,
|
|
45
|
+
variables: Optional[Dict[str, str]] = None,
|
|
46
|
+
headers: Optional[Dict[str, str]] = None,
|
|
47
|
+
scheduled_for: Optional[Union[str, datetime]] = None,
|
|
48
|
+
timezone: Optional[str] = None,
|
|
49
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
50
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
51
|
+
reply_to: Optional[str] = None,
|
|
52
|
+
) -> PosthawkResponse[SendEmailResponse]:
|
|
53
|
+
"""Send an email immediately or schedule it for later."""
|
|
54
|
+
body: Dict[str, Any] = {
|
|
55
|
+
"from": from_email,
|
|
56
|
+
"to": _to_list(to),
|
|
57
|
+
"subject": subject,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
optionals: Dict[str, Any] = {
|
|
61
|
+
"cc": _to_list(cc),
|
|
62
|
+
"bcc": _to_list(bcc),
|
|
63
|
+
"html": html,
|
|
64
|
+
"text": text,
|
|
65
|
+
"templateId": template_id,
|
|
66
|
+
"variables": variables,
|
|
67
|
+
"headers": headers,
|
|
68
|
+
"scheduledFor": _to_iso(scheduled_for),
|
|
69
|
+
"timezone": timezone,
|
|
70
|
+
"metadata": metadata,
|
|
71
|
+
"tags": tags,
|
|
72
|
+
"replyTo": reply_to,
|
|
73
|
+
}
|
|
74
|
+
body.update({k: v for k, v in optionals.items() if v is not None})
|
|
75
|
+
|
|
76
|
+
resp = self._http.request("POST", "/v1/send", json=body)
|
|
77
|
+
if resp.error:
|
|
78
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
79
|
+
|
|
80
|
+
return PosthawkResponse(
|
|
81
|
+
data=SendEmailResponse(**resp.data),
|
|
82
|
+
error=None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def get(self, job_id: str) -> PosthawkResponse[EmailJobStatus]:
|
|
86
|
+
"""Check the status of a previously queued email job."""
|
|
87
|
+
resp = self._http.request("GET", f"/v1/send/{quote(job_id, safe='')}")
|
|
88
|
+
if resp.error:
|
|
89
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
90
|
+
|
|
91
|
+
raw = dict(resp.data)
|
|
92
|
+
result_data = raw.pop("result", None)
|
|
93
|
+
result = EmailJobResult(**result_data) if result_data else None
|
|
94
|
+
|
|
95
|
+
return PosthawkResponse(
|
|
96
|
+
data=EmailJobStatus(**raw, result=result),
|
|
97
|
+
error=None,
|
|
98
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class PosthawkError(Exception):
|
|
2
|
+
"""Error returned by the Posthawk API.
|
|
3
|
+
|
|
4
|
+
SDK methods never raise this for API errors — they return
|
|
5
|
+
PosthawkResponse(data=None, error=PosthawkError(...)) instead.
|
|
6
|
+
|
|
7
|
+
Only the Posthawk constructor raises this directly (e.g. missing API key).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, status_code: int = 500):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.message = message
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return f"PosthawkError(message={self.message!r}, status_code={self.status_code})"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
from .types import (
|
|
9
|
+
CancelResponse,
|
|
10
|
+
PosthawkResponse,
|
|
11
|
+
RescheduleResponse,
|
|
12
|
+
ScheduledEmail,
|
|
13
|
+
ScheduledGetResponse,
|
|
14
|
+
ScheduledListResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Scheduled:
|
|
19
|
+
"""Manage scheduled emails."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, http: HttpClient) -> None:
|
|
22
|
+
self._http = http
|
|
23
|
+
|
|
24
|
+
def list(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
status: Optional[str] = None,
|
|
28
|
+
limit: Optional[int] = None,
|
|
29
|
+
offset: Optional[int] = None,
|
|
30
|
+
) -> PosthawkResponse[ScheduledListResponse]:
|
|
31
|
+
"""List scheduled emails with optional filtering."""
|
|
32
|
+
params: Dict[str, Any] = {}
|
|
33
|
+
if status is not None:
|
|
34
|
+
params["status"] = status
|
|
35
|
+
if limit is not None:
|
|
36
|
+
params["limit"] = str(limit)
|
|
37
|
+
if offset is not None:
|
|
38
|
+
params["offset"] = str(offset)
|
|
39
|
+
|
|
40
|
+
resp = self._http.request("GET", "/scheduled", params=params or None)
|
|
41
|
+
if resp.error:
|
|
42
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
43
|
+
|
|
44
|
+
raw = resp.data
|
|
45
|
+
emails = [ScheduledEmail(**e) for e in raw.get("data", [])]
|
|
46
|
+
|
|
47
|
+
return PosthawkResponse(
|
|
48
|
+
data=ScheduledListResponse(
|
|
49
|
+
success=raw.get("success", True),
|
|
50
|
+
data=emails,
|
|
51
|
+
total=raw.get("total", 0),
|
|
52
|
+
),
|
|
53
|
+
error=None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get(self, id: str) -> PosthawkResponse[ScheduledGetResponse]:
|
|
57
|
+
"""Get a specific scheduled email by ID."""
|
|
58
|
+
resp = self._http.request("GET", f"/scheduled/{quote(id, safe='')}")
|
|
59
|
+
if resp.error:
|
|
60
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
61
|
+
|
|
62
|
+
raw = resp.data
|
|
63
|
+
return PosthawkResponse(
|
|
64
|
+
data=ScheduledGetResponse(
|
|
65
|
+
success=raw.get("success", True),
|
|
66
|
+
data=ScheduledEmail(**raw["data"]),
|
|
67
|
+
),
|
|
68
|
+
error=None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def cancel(self, id: str) -> PosthawkResponse[CancelResponse]:
|
|
72
|
+
"""Cancel a scheduled email before it sends."""
|
|
73
|
+
resp = self._http.request("DELETE", f"/scheduled/{quote(id, safe='')}")
|
|
74
|
+
if resp.error:
|
|
75
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
76
|
+
|
|
77
|
+
raw = resp.data
|
|
78
|
+
return PosthawkResponse(
|
|
79
|
+
data=CancelResponse(
|
|
80
|
+
success=raw.get("success", True),
|
|
81
|
+
message=raw.get("message", ""),
|
|
82
|
+
),
|
|
83
|
+
error=None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def reschedule(
|
|
87
|
+
self,
|
|
88
|
+
id: str,
|
|
89
|
+
*,
|
|
90
|
+
scheduled_for: Union[str, datetime],
|
|
91
|
+
) -> PosthawkResponse[RescheduleResponse]:
|
|
92
|
+
"""Reschedule an email to a new send time."""
|
|
93
|
+
iso = (
|
|
94
|
+
scheduled_for.isoformat()
|
|
95
|
+
if isinstance(scheduled_for, datetime)
|
|
96
|
+
else scheduled_for
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
resp = self._http.request(
|
|
100
|
+
"PATCH",
|
|
101
|
+
f"/scheduled/{quote(id, safe='')}/reschedule",
|
|
102
|
+
json={"scheduledFor": iso},
|
|
103
|
+
)
|
|
104
|
+
if resp.error:
|
|
105
|
+
return PosthawkResponse(data=None, error=resp.error)
|
|
106
|
+
|
|
107
|
+
raw = resp.data
|
|
108
|
+
return PosthawkResponse(
|
|
109
|
+
data=RescheduleResponse(
|
|
110
|
+
success=raw.get("success", True),
|
|
111
|
+
data=ScheduledEmail(**raw["data"]),
|
|
112
|
+
),
|
|
113
|
+
error=None,
|
|
114
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Generic, List, Optional, TypeVar
|
|
5
|
+
|
|
6
|
+
from .error import PosthawkError
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PosthawkResponse(Generic[T]):
|
|
13
|
+
"""Every SDK method returns this. Check .error first."""
|
|
14
|
+
|
|
15
|
+
data: Optional[T]
|
|
16
|
+
error: Optional[PosthawkError]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -- Email responses --
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SendEmailResponse:
|
|
24
|
+
success: bool
|
|
25
|
+
scheduled: bool
|
|
26
|
+
message: str
|
|
27
|
+
status_url: str
|
|
28
|
+
job_id: Optional[str] = None
|
|
29
|
+
id: Optional[str] = None
|
|
30
|
+
scheduled_for: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EmailJobResult:
|
|
35
|
+
success: Optional[bool] = None
|
|
36
|
+
message_id: Optional[str] = None
|
|
37
|
+
email_log_id: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EmailJobStatus:
|
|
42
|
+
job_id: str
|
|
43
|
+
status: str # pending | processing | completed | failed
|
|
44
|
+
created_at: str
|
|
45
|
+
progress: Optional[float] = None
|
|
46
|
+
result: Optional[EmailJobResult] = None
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
processed_at: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -- Scheduled responses --
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ScheduledEmail:
|
|
56
|
+
id: str
|
|
57
|
+
from_email: str
|
|
58
|
+
to_emails: List[str]
|
|
59
|
+
subject: str
|
|
60
|
+
scheduled_for: str
|
|
61
|
+
status: str # scheduled | sent | cancelled | failed
|
|
62
|
+
created_at: str
|
|
63
|
+
cc_emails: Optional[List[str]] = None
|
|
64
|
+
bcc_emails: Optional[List[str]] = None
|
|
65
|
+
timezone: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ScheduledListResponse:
|
|
70
|
+
success: bool
|
|
71
|
+
data: List[ScheduledEmail]
|
|
72
|
+
total: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ScheduledGetResponse:
|
|
77
|
+
success: bool
|
|
78
|
+
data: ScheduledEmail
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CancelResponse:
|
|
83
|
+
success: bool
|
|
84
|
+
message: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RescheduleResponse:
|
|
89
|
+
success: bool
|
|
90
|
+
data: ScheduledEmail
|