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.
@@ -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
@@ -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
@@ -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