stackshift 0.1.2__tar.gz → 1.0.1__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.
- stackshift-1.0.1/PKG-INFO +215 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/README.md +2 -2
- {stackshift-0.1.2 → stackshift-1.0.1}/pyproject.toml +2 -1
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/__init__.py +8 -1
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/assets.py +3 -1
- stackshift-1.0.1/stackshift/assets_workflows.py +176 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/client.py +1 -1
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/mail.py +35 -3
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/mail_advanced.py +21 -1
- stackshift-1.0.1/stackshift/mail_campaigns.py +61 -0
- stackshift-0.1.2/stackshift/mail_phase7.py → stackshift-1.0.1/stackshift/mail_events_webhooks.py +8 -0
- stackshift-1.0.1/stackshift/mail_exports.py +30 -0
- stackshift-1.0.1/stackshift/mail_streams.py +18 -0
- stackshift-1.0.1/stackshift.egg-info/PKG-INFO +215 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift.egg-info/SOURCES.txt +9 -2
- stackshift-1.0.1/tests/test_assets_workflows.py +34 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/tests/test_mail.py +7 -7
- {stackshift-0.1.2 → stackshift-1.0.1}/tests/test_mail_advanced.py +11 -2
- stackshift-1.0.1/tests/test_mail_diagnostics.py +22 -0
- stackshift-0.1.2/tests/test_mail_phase7.py → stackshift-1.0.1/tests/test_mail_events_webhooks.py +8 -8
- stackshift-1.0.1/tests/test_mail_exports.py +30 -0
- stackshift-0.1.2/PKG-INFO +0 -6
- stackshift-0.1.2/stackshift.egg-info/PKG-INFO +0 -6
- {stackshift-0.1.2 → stackshift-1.0.1}/setup.cfg +0 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/mail_reputation.py +0 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift/projects.py +0 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift.egg-info/dependency_links.txt +0 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift.egg-info/requires.txt +0 -0
- {stackshift-0.1.2 → stackshift-1.0.1}/stackshift.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stackshift
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Official Python SDK for StackShift.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
|
|
9
|
+
# StackShift Python SDK
|
|
10
|
+
|
|
11
|
+
Official Python SDK for StackShift.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install stackshift
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Send email
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from stackshift import StackShift
|
|
23
|
+
|
|
24
|
+
stackshift = StackShift()
|
|
25
|
+
|
|
26
|
+
message = stackshift.mail.send(
|
|
27
|
+
from_="StackShift <noreply@mail.stackshift.cloud>",
|
|
28
|
+
to="ada@example.com",
|
|
29
|
+
subject="Welcome",
|
|
30
|
+
text="Welcome to StackShift.",
|
|
31
|
+
idempotency_key="welcome:user_123",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
print(message["id"], message["status"], message["idempotencyStatus"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Inspect message status:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
|
|
41
|
+
detail = stackshift.mail.messages.get(messages["data"][0]["id"])
|
|
42
|
+
attempts = stackshift.mail.messages.attempts(detail["id"])
|
|
43
|
+
logs = stackshift.mail.messages.logs(detail["id"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`mta_accepted` means the message was accepted by StackShift's outbound MTA. It does not mean recipient-MX acceptance, inbox placement, opens, clicks, or spam placement. Recipient records later transition independently to `delayed`, `delivered`, `bounced`, `failed`, `suppressed`, or `complained`.
|
|
47
|
+
|
|
48
|
+
## Events and webhooks
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
events = stackshift.mail.events.list(
|
|
52
|
+
type="mail.message.bounced",
|
|
53
|
+
message_id="msg_123",
|
|
54
|
+
limit=20,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
event = stackshift.mail.events.get("evt_123")
|
|
58
|
+
timeline = stackshift.mail.messages.timeline("msg_123")
|
|
59
|
+
|
|
60
|
+
webhook = stackshift.mail.webhooks.create(
|
|
61
|
+
url="https://example.com/stackshift-mail",
|
|
62
|
+
event_types=["mail.message.bounced", "mail.otp.verified"],
|
|
63
|
+
)
|
|
64
|
+
print(webhook["id"], webhook["secret"]) # Secret is only returned on create/rotate.
|
|
65
|
+
|
|
66
|
+
deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
|
|
67
|
+
retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Verify a webhook signature before processing the payload:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
valid = stackshift.mail.webhooks.verify_signature(
|
|
74
|
+
raw_body=request.get_data(),
|
|
75
|
+
signature_header=request.headers.get("StackShift-Signature"),
|
|
76
|
+
timestamp_header=request.headers.get("StackShift-Timestamp"),
|
|
77
|
+
secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
|
|
82
|
+
|
|
83
|
+
## Send a template
|
|
84
|
+
|
|
85
|
+
Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
stackshift.mail.templates.create(
|
|
89
|
+
name="Welcome Email",
|
|
90
|
+
slug="welcome-email",
|
|
91
|
+
subject="Welcome, {{name}}",
|
|
92
|
+
text="Welcome, {{name}}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
preview = stackshift.mail.templates.preview(
|
|
96
|
+
"welcome-email",
|
|
97
|
+
data={"name": "Ada"},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
message = stackshift.mail.send_template(
|
|
101
|
+
template="welcome-email",
|
|
102
|
+
to="ada@example.com",
|
|
103
|
+
from_="Acme <noreply@acme.com>",
|
|
104
|
+
data={"name": "Ada"},
|
|
105
|
+
idempotency_key="welcome:user_123",
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Missing variables fail before a message is queued. Template sends use the same sender-domain, suppression, idempotency, Durable Jobs, and Postfix handoff pipeline as `mail.send`.
|
|
110
|
+
|
|
111
|
+
## Bounces and suppressions
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
suppressions = stackshift.mail.suppressions.list()
|
|
115
|
+
|
|
116
|
+
manual = stackshift.mail.suppressions.create(
|
|
117
|
+
email="bad@example.com",
|
|
118
|
+
reason="manual",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
stackshift.mail.suppressions.delete(manual["id"])
|
|
122
|
+
|
|
123
|
+
bounces = stackshift.mail.bounces.list(type="hard")
|
|
124
|
+
message_bounces = stackshift.mail.messages.bounces("msg_123")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.
|
|
128
|
+
|
|
129
|
+
## Verify a sending domain
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
domain = stackshift.mail.domains.create("acme.com")
|
|
133
|
+
print(domain["records"])
|
|
134
|
+
|
|
135
|
+
stackshift.mail.domains.verify(domain["id"])
|
|
136
|
+
|
|
137
|
+
message = stackshift.mail.send(
|
|
138
|
+
from_="Acme <noreply@acme.com>",
|
|
139
|
+
to="user@example.com",
|
|
140
|
+
subject="Welcome",
|
|
141
|
+
html="<h1>Welcome</h1>",
|
|
142
|
+
text="Welcome",
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Add the returned SPF, DKIM, and return-path DNS records before verification. DMARC is recommended unless your environment sets `MAIL_DMARC_REQUIRED=true`. DNS propagation can take time.
|
|
147
|
+
|
|
148
|
+
## Upload an asset
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from stackshift import StackShift
|
|
152
|
+
|
|
153
|
+
stackshift = StackShift()
|
|
154
|
+
|
|
155
|
+
asset = stackshift.assets.upload(
|
|
156
|
+
"avatar.png",
|
|
157
|
+
folder="avatars",
|
|
158
|
+
visibility="public",
|
|
159
|
+
metadata={"user_id": "user_123"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
print(asset["url"])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.
|
|
166
|
+
|
|
167
|
+
## Private asset URL
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
signed = stackshift.assets.signed_url(
|
|
171
|
+
asset["id"],
|
|
172
|
+
expires_in="10m",
|
|
173
|
+
max_downloads=1,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
print(signed["url"])
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Image transformations
|
|
180
|
+
|
|
181
|
+
Use built-in presets or create your own named transformations.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from stackshift import StackShift, asset_transform_options, get_asset_transform_preset
|
|
185
|
+
|
|
186
|
+
stackshift = StackShift()
|
|
187
|
+
hero = get_asset_transform_preset("hero")
|
|
188
|
+
hero_options = asset_transform_options(hero)
|
|
189
|
+
|
|
190
|
+
stackshift.assets.create_transformation(hero["name"], **hero_options)
|
|
191
|
+
|
|
192
|
+
named = stackshift.assets.named_url("asset_123", hero["name"])
|
|
193
|
+
signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")
|
|
194
|
+
|
|
195
|
+
stackshift.assets.delete_transformation("old-preset")
|
|
196
|
+
print(named, signed["url"])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Direct browser uploads
|
|
200
|
+
|
|
201
|
+
Create the upload session on your Python backend:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
upload = stackshift.assets.signed_upload_url(
|
|
205
|
+
bucket="avatars",
|
|
206
|
+
key="users/user_123.png",
|
|
207
|
+
visibility="public",
|
|
208
|
+
expiresIn="10m",
|
|
209
|
+
maxBytes=5_000_000,
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
|
|
214
|
+
|
|
215
|
+
The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
|
|
@@ -29,13 +29,13 @@ print(message["id"], message["status"], message["idempotencyStatus"])
|
|
|
29
29
|
Inspect message status:
|
|
30
30
|
|
|
31
31
|
```python
|
|
32
|
-
messages = stackshift.mail.messages.list(status="
|
|
32
|
+
messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
|
|
33
33
|
detail = stackshift.mail.messages.get(messages["data"][0]["id"])
|
|
34
34
|
attempts = stackshift.mail.messages.attempts(detail["id"])
|
|
35
35
|
logs = stackshift.mail.messages.logs(detail["id"])
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
`mta_accepted` means the message was accepted by StackShift's outbound MTA. It does not mean recipient-MX acceptance, inbox placement, opens, clicks, or spam placement. Recipient records later transition independently to `delayed`, `delivered`, `bounced`, `failed`, `suppressed`, or `complained`.
|
|
39
39
|
|
|
40
40
|
## Events and webhooks
|
|
41
41
|
|
|
@@ -8,7 +8,10 @@ from .mail import (
|
|
|
8
8
|
MailTemplatesClient,
|
|
9
9
|
)
|
|
10
10
|
from .mail_reputation import MailLimitsClient, MailReputationClient, MailReputationEventsClient
|
|
11
|
-
from .
|
|
11
|
+
from .mail_events_webhooks import MailEventsClient, MailWebhooksClient, verify_webhook_signature
|
|
12
|
+
from .mail_streams import MailStreamsClient
|
|
13
|
+
from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
|
|
14
|
+
from .mail_exports import MailExportsClient
|
|
12
15
|
from .projects import ProjectsClient
|
|
13
16
|
|
|
14
17
|
__all__ = [
|
|
@@ -25,6 +28,10 @@ __all__ = [
|
|
|
25
28
|
"MailReputationEventsClient",
|
|
26
29
|
"MailTemplatesClient",
|
|
27
30
|
"MailWebhooksClient",
|
|
31
|
+
"MailStreamsClient",
|
|
32
|
+
"MailAudiencesClient",
|
|
33
|
+
"MailCampaignsClient",
|
|
34
|
+
"MailExportsClient",
|
|
28
35
|
"ProjectsClient",
|
|
29
36
|
"asset_transform_options",
|
|
30
37
|
"asset_transform_spec",
|
|
@@ -6,6 +6,8 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any, BinaryIO
|
|
7
7
|
from urllib.parse import quote
|
|
8
8
|
|
|
9
|
+
from .assets_workflows import AssetWorkflowsMixin
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
|
|
11
13
|
{"id": "web-thumbnail", "name": "web-thumbnail", "category": "responsive-web", "description": "Compact grid thumbnail for libraries and dashboards.", "width": 320, "height": 180, "crop": "fill", "format": "webp", "quality": 78},
|
|
@@ -47,7 +49,7 @@ def asset_transform_spec(**options: Any) -> str:
|
|
|
47
49
|
return _transform_spec(options)
|
|
48
50
|
|
|
49
51
|
|
|
50
|
-
class AssetsClient:
|
|
52
|
+
class AssetsClient(AssetWorkflowsMixin):
|
|
51
53
|
def __init__(self, client: Any) -> None:
|
|
52
54
|
self._client = client
|
|
53
55
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AssetWorkflowsMixin:
|
|
8
|
+
"""Typed route helpers for deterministic media and governed asset workflows."""
|
|
9
|
+
|
|
10
|
+
_client: Any
|
|
11
|
+
|
|
12
|
+
def create_transformation_v2(
|
|
13
|
+
self, name: str, definition: dict[str, Any], *, eager: bool = False
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
return self._client.request(
|
|
16
|
+
"POST", "/assets/transformations",
|
|
17
|
+
json={"name": name, "definition": definition, "eager": eager},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def materialize_transform(
|
|
21
|
+
self,
|
|
22
|
+
asset_id: str,
|
|
23
|
+
*,
|
|
24
|
+
definition: dict[str, Any] | None = None,
|
|
25
|
+
preset: str | None = None,
|
|
26
|
+
idempotency_key: str | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
payload = {key: value for key, value in {
|
|
29
|
+
"definition": definition, "preset": preset,
|
|
30
|
+
}.items() if value not in (None, "")}
|
|
31
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
32
|
+
return self._client.request(
|
|
33
|
+
"POST", f"/assets/{quote(asset_id, safe='')}/transforms", json=payload, headers=headers
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def derivatives(self, asset_id: str) -> dict[str, Any]:
|
|
37
|
+
return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/derivatives")
|
|
38
|
+
|
|
39
|
+
def generate(
|
|
40
|
+
self, payload: dict[str, Any], *, idempotency_key: str
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
return self._client.request(
|
|
43
|
+
"POST", "/assets/generations", json=payload,
|
|
44
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def automation(self) -> dict[str, Any]:
|
|
48
|
+
return self._client.request("GET", "/assets/automation")
|
|
49
|
+
|
|
50
|
+
def workflow_catalog(self) -> dict[str, Any]:
|
|
51
|
+
return self._client.request("GET", "/assets/workflows/catalog")
|
|
52
|
+
|
|
53
|
+
def create_workflow(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
return self._client.request("POST", "/assets/workflows", json=payload)
|
|
55
|
+
|
|
56
|
+
def workflows(self, *, page: int = 1, per_page: int = 20) -> list[dict[str, Any]]:
|
|
57
|
+
query = urlencode({"page": page, "per_page": per_page})
|
|
58
|
+
return self._client.request("GET", f"/assets/workflows?{query}")
|
|
59
|
+
|
|
60
|
+
def workflow(self, workflow_id: str) -> dict[str, Any]:
|
|
61
|
+
return self._client.request("GET", self._workflow_path(workflow_id))
|
|
62
|
+
|
|
63
|
+
def update_workflow_draft(
|
|
64
|
+
self, workflow_id: str, payload: dict[str, Any]
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
return self._client.request(
|
|
67
|
+
"PUT", f"{self._workflow_path(workflow_id)}/draft", json=payload
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def validate_workflow(self, workflow_id: str) -> dict[str, Any]:
|
|
71
|
+
return self._client.request("POST", f"{self._workflow_path(workflow_id)}/validate")
|
|
72
|
+
|
|
73
|
+
def publish_workflow(
|
|
74
|
+
self, workflow_id: str, expected_revision: int
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
return self._client.request(
|
|
77
|
+
"POST", f"{self._workflow_path(workflow_id)}/publish",
|
|
78
|
+
json={"expected_revision": expected_revision},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def activate_workflow(
|
|
82
|
+
self, workflow_id: str, version_id: str, expected_revision: int
|
|
83
|
+
) -> None:
|
|
84
|
+
self._client.request(
|
|
85
|
+
"POST", f"{self._workflow_path(workflow_id)}/activate",
|
|
86
|
+
json={"version_id": version_id, "expected_revision": expected_revision},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def workflow_versions(self, workflow_id: str) -> list[dict[str, Any]]:
|
|
90
|
+
return self._client.request("GET", f"{self._workflow_path(workflow_id)}/versions")
|
|
91
|
+
|
|
92
|
+
def run_workflow(
|
|
93
|
+
self, workflow_id: str, payload: dict[str, Any] | None = None,
|
|
94
|
+
*, idempotency_key: str | None = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
97
|
+
return self._client.request(
|
|
98
|
+
"POST", f"{self._workflow_path(workflow_id)}/run",
|
|
99
|
+
json=payload or {}, headers=headers,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def workflow_runs(
|
|
103
|
+
self, workflow_id: str, *, page: int = 1, per_page: int = 20
|
|
104
|
+
) -> list[dict[str, Any]]:
|
|
105
|
+
query = urlencode({"page": page, "per_page": per_page})
|
|
106
|
+
return self._client.request(
|
|
107
|
+
"GET", f"{self._workflow_path(workflow_id)}/runs?{query}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def workflow_run(self, run_id: str) -> dict[str, Any]:
|
|
111
|
+
return self._client.request("GET", self._workflow_run_path(run_id))
|
|
112
|
+
|
|
113
|
+
def cancel_workflow_run(self, run_id: str) -> dict[str, Any]:
|
|
114
|
+
return self._client.request("POST", f"{self._workflow_run_path(run_id)}/cancel")
|
|
115
|
+
|
|
116
|
+
def retry_workflow_run(self, run_id: str) -> dict[str, Any]:
|
|
117
|
+
return self._client.request("POST", f"{self._workflow_run_path(run_id)}/retry")
|
|
118
|
+
|
|
119
|
+
def workflow_approvals(self, status: str | None = None) -> list[dict[str, Any]]:
|
|
120
|
+
if status:
|
|
121
|
+
return self._client.request(
|
|
122
|
+
"GET", f"/assets/workflows/approvals?{urlencode({'status': status})}"
|
|
123
|
+
)
|
|
124
|
+
return self._client.request("GET", "/assets/workflows/approvals")
|
|
125
|
+
|
|
126
|
+
def decide_workflow_approval(
|
|
127
|
+
self, approval_id: str, approved: bool, payload: dict[str, Any] | None = None
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
return (
|
|
130
|
+
self.approve_workflow(approval_id, payload)
|
|
131
|
+
if approved else self.reject_workflow(approval_id, payload)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def approve_workflow(
|
|
135
|
+
self, approval_id: str, payload: dict[str, Any] | None = None
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
return self._client.request(
|
|
138
|
+
"POST", f"/assets/workflows/approvals/{quote(approval_id, safe='')}/approve",
|
|
139
|
+
json=payload or {},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def reject_workflow(
|
|
143
|
+
self, approval_id: str, payload: dict[str, Any] | None = None
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
return self._client.request(
|
|
146
|
+
"POST", f"/assets/workflows/approvals/{quote(approval_id, safe='')}/reject",
|
|
147
|
+
json=payload or {},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def workflow_connections(self) -> list[dict[str, Any]]:
|
|
151
|
+
return self._client.request("GET", "/assets/workflows/connections")
|
|
152
|
+
|
|
153
|
+
def create_workflow_connection(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
return self._client.request("POST", "/assets/workflows/connections", json=payload)
|
|
155
|
+
|
|
156
|
+
def rotate_workflow_connection_credential(
|
|
157
|
+
self, connection_id: str, payload: dict[str, Any]
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
return self._client.request(
|
|
160
|
+
"POST",
|
|
161
|
+
f"/assets/workflows/connections/{quote(connection_id, safe='')}/credential/rotate",
|
|
162
|
+
json=payload,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def rotate_workflow_inbound_hook(self, workflow_id: str) -> dict[str, Any]:
|
|
166
|
+
return self._client.request(
|
|
167
|
+
"POST", f"{self._workflow_path(workflow_id)}/inbound-hook/rotate"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _workflow_path(workflow_id: str) -> str:
|
|
172
|
+
return f"/assets/workflows/{quote(workflow_id, safe='')}"
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _workflow_run_path(run_id: str) -> str:
|
|
176
|
+
return f"/assets/workflows/runs/{quote(run_id, safe='')}"
|
|
@@ -43,7 +43,7 @@ class StackShift:
|
|
|
43
43
|
def _request(self, base_url: str, method: str, path: str, **kwargs: Any) -> Any:
|
|
44
44
|
headers = kwargs.pop("headers", {})
|
|
45
45
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
46
|
-
headers["User-Agent"] = "StackShift-Python/0
|
|
46
|
+
headers["User-Agent"] = "StackShift-Python/1.0"
|
|
47
47
|
|
|
48
48
|
response = self.session.request(method, f"{base_url}{path}", headers=headers, **kwargs)
|
|
49
49
|
payload = response.json() if response.content else None
|
|
@@ -4,8 +4,11 @@ from typing import Any
|
|
|
4
4
|
from urllib.parse import quote
|
|
5
5
|
|
|
6
6
|
from .mail_advanced import MailAnalyticsClient, MailBatchClient, MailInboundClient, MailScheduledClient
|
|
7
|
-
from .
|
|
7
|
+
from .mail_events_webhooks import MailEventsClient, MailWebhooksClient
|
|
8
8
|
from .mail_reputation import MailLimitsClient, MailReputationClient
|
|
9
|
+
from .mail_streams import MailStreamsClient
|
|
10
|
+
from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
|
|
11
|
+
from .mail_exports import MailExportsClient
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class MailClient:
|
|
@@ -19,6 +22,10 @@ class MailClient:
|
|
|
19
22
|
self.templates = MailTemplatesClient(client)
|
|
20
23
|
self.events = MailEventsClient(client)
|
|
21
24
|
self.webhooks = MailWebhooksClient(client)
|
|
25
|
+
self.streams = MailStreamsClient(client)
|
|
26
|
+
self.audiences = MailAudiencesClient(client)
|
|
27
|
+
self.campaigns = MailCampaignsClient(client)
|
|
28
|
+
self.exports = MailExportsClient(client)
|
|
22
29
|
self.limits = MailLimitsClient(client)
|
|
23
30
|
self.reputation = MailReputationClient(client)
|
|
24
31
|
self.scheduled = MailScheduledClient(client)
|
|
@@ -39,6 +46,9 @@ class MailClient:
|
|
|
39
46
|
reply_to: str | dict[str, str] | None = None,
|
|
40
47
|
idempotency_key: str | None = None,
|
|
41
48
|
attachments: list[dict[str, Any]] | None = None,
|
|
49
|
+
headers: dict[str, str] | None = None,
|
|
50
|
+
tags: dict[str, str] | None = None,
|
|
51
|
+
stream: str | None = None,
|
|
42
52
|
) -> dict[str, Any]:
|
|
43
53
|
payload = {
|
|
44
54
|
"from": from_,
|
|
@@ -51,6 +61,9 @@ class MailClient:
|
|
|
51
61
|
"text": text,
|
|
52
62
|
"idempotencyKey": idempotency_key,
|
|
53
63
|
"attachments": attachments,
|
|
64
|
+
"headers": headers,
|
|
65
|
+
"tags": tags,
|
|
66
|
+
"stream": stream,
|
|
54
67
|
}
|
|
55
68
|
clean = {key: value for key, value in payload.items() if value is not None}
|
|
56
69
|
return self._client.mail_request("POST", "/mail/send", json=clean)
|
|
@@ -68,6 +81,9 @@ class MailClient:
|
|
|
68
81
|
reply_to: str | dict[str, str] | None = None,
|
|
69
82
|
idempotency_key: str | None = None,
|
|
70
83
|
attachments: list[dict[str, Any]] | None = None,
|
|
84
|
+
headers: dict[str, str] | None = None,
|
|
85
|
+
tags: dict[str, str] | None = None,
|
|
86
|
+
stream: str | None = None,
|
|
71
87
|
) -> dict[str, Any]:
|
|
72
88
|
payload = {
|
|
73
89
|
"template": template,
|
|
@@ -80,6 +96,9 @@ class MailClient:
|
|
|
80
96
|
"data": data,
|
|
81
97
|
"idempotencyKey": idempotency_key,
|
|
82
98
|
"attachments": attachments,
|
|
99
|
+
"headers": headers,
|
|
100
|
+
"tags": tags,
|
|
101
|
+
"stream": stream,
|
|
83
102
|
}
|
|
84
103
|
clean = {key: value for key, value in payload.items() if value is not None}
|
|
85
104
|
return self._client.mail_request("POST", "/mail/send-template", json=clean)
|
|
@@ -162,6 +181,18 @@ class MailTemplatesClient:
|
|
|
162
181
|
clean = {key: value for key, value in payload.items() if value is not None}
|
|
163
182
|
return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/preview", json=clean)
|
|
164
183
|
|
|
184
|
+
def diagnostics(
|
|
185
|
+
self,
|
|
186
|
+
template_id: str,
|
|
187
|
+
*,
|
|
188
|
+
data: dict[str, Any] | None = None,
|
|
189
|
+
version_id: str | None = None,
|
|
190
|
+
stream: str | None = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
payload = {"data": data, "versionId": version_id, "stream": stream}
|
|
193
|
+
clean = {key: value for key, value in payload.items() if value is not None}
|
|
194
|
+
return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/diagnostics", json=clean)
|
|
195
|
+
|
|
165
196
|
def test_send(
|
|
166
197
|
self,
|
|
167
198
|
template_id: str,
|
|
@@ -231,8 +262,9 @@ class MailMessagesClient:
|
|
|
231
262
|
def bounces(self, message_id: str) -> dict[str, Any]:
|
|
232
263
|
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/bounces")
|
|
233
264
|
|
|
234
|
-
def timeline(self, message_id: str) -> dict[str, Any]:
|
|
235
|
-
|
|
265
|
+
def timeline(self, message_id: str, *, recipient: str | None = None) -> dict[str, Any]:
|
|
266
|
+
params = {"recipient": recipient} if recipient else None
|
|
267
|
+
return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/timeline", params=params)
|
|
236
268
|
|
|
237
269
|
|
|
238
270
|
class MailSuppressionsClient:
|
|
@@ -36,11 +36,31 @@ class MailBatchClient:
|
|
|
36
36
|
from_: str | dict[str, str],
|
|
37
37
|
recipients: list[dict[str, Any]],
|
|
38
38
|
version_id: str | None = None,
|
|
39
|
+
headers: dict[str, str] | None = None,
|
|
40
|
+
tags: dict[str, str] | None = None,
|
|
41
|
+
stream: str | None = None,
|
|
39
42
|
) -> dict[str, Any]:
|
|
40
|
-
payload = {
|
|
43
|
+
payload = {
|
|
44
|
+
"template": template,
|
|
45
|
+
"from": from_,
|
|
46
|
+
"recipients": recipients,
|
|
47
|
+
"versionId": version_id,
|
|
48
|
+
"headers": headers,
|
|
49
|
+
"tags": tags,
|
|
50
|
+
"stream": stream,
|
|
51
|
+
}
|
|
41
52
|
clean = {key: value for key, value in payload.items() if value is not None}
|
|
42
53
|
return self._client.mail_request("POST", "/mail/batch-template", json=clean)
|
|
43
54
|
|
|
55
|
+
def list(self) -> dict[str, Any]:
|
|
56
|
+
return self._client.mail_request("GET", "/mail/batches")
|
|
57
|
+
|
|
58
|
+
def get(self, batch_id: str) -> dict[str, Any]:
|
|
59
|
+
return self._client.mail_request("GET", f"/mail/batches/{quote(batch_id)}")
|
|
60
|
+
|
|
61
|
+
def items(self, batch_id: str) -> dict[str, Any]:
|
|
62
|
+
return self._client.mail_request("GET", f"/mail/batches/{quote(batch_id)}/items")
|
|
63
|
+
|
|
44
64
|
|
|
45
65
|
class MailInboundClient:
|
|
46
66
|
def __init__(self, client: Any) -> None:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailAudiencesClient:
|
|
8
|
+
def __init__(self, client: Any) -> None:
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def create(self, *, name: str, description: str | None = None) -> dict[str, Any]:
|
|
12
|
+
payload = {"name": name, "description": description}
|
|
13
|
+
return self._client.mail_request("POST", "/mail/audiences", json={key: value for key, value in payload.items() if value is not None})
|
|
14
|
+
|
|
15
|
+
def list(self) -> dict[str, Any]:
|
|
16
|
+
return self._client.mail_request("GET", "/mail/audiences")
|
|
17
|
+
|
|
18
|
+
def get(self, audience_id: str) -> dict[str, Any]:
|
|
19
|
+
return self._client.mail_request("GET", f"/mail/audiences/{quote(audience_id)}")
|
|
20
|
+
|
|
21
|
+
def members(self, audience_id: str) -> dict[str, Any]:
|
|
22
|
+
return self._client.mail_request("GET", f"/mail/audiences/{quote(audience_id)}/members")
|
|
23
|
+
|
|
24
|
+
def import_members(self, audience_id: str, members: list[dict[str, Any]]) -> dict[str, Any]:
|
|
25
|
+
return self._client.mail_request(
|
|
26
|
+
"POST",
|
|
27
|
+
f"/mail/audiences/{quote(audience_id)}/imports",
|
|
28
|
+
json={"members": members},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MailCampaignsClient:
|
|
33
|
+
def __init__(self, client: Any) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
|
|
36
|
+
def create(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
name: str,
|
|
40
|
+
audience_id: str,
|
|
41
|
+
template: str,
|
|
42
|
+
from_: str,
|
|
43
|
+
version_id: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
payload = {
|
|
46
|
+
"name": name,
|
|
47
|
+
"audienceId": audience_id,
|
|
48
|
+
"template": template,
|
|
49
|
+
"from": from_,
|
|
50
|
+
"versionId": version_id,
|
|
51
|
+
}
|
|
52
|
+
return self._client.mail_request("POST", "/mail/campaigns", json={key: value for key, value in payload.items() if value is not None})
|
|
53
|
+
|
|
54
|
+
def list(self) -> dict[str, Any]:
|
|
55
|
+
return self._client.mail_request("GET", "/mail/campaigns")
|
|
56
|
+
|
|
57
|
+
def get(self, campaign_id: str) -> dict[str, Any]:
|
|
58
|
+
return self._client.mail_request("GET", f"/mail/campaigns/{quote(campaign_id)}")
|
|
59
|
+
|
|
60
|
+
def send(self, campaign_id: str) -> dict[str, Any]:
|
|
61
|
+
return self._client.mail_request("POST", f"/mail/campaigns/{quote(campaign_id)}/send")
|
stackshift-0.1.2/stackshift/mail_phase7.py → stackshift-1.0.1/stackshift/mail_events_webhooks.py
RENAMED
|
@@ -100,6 +100,14 @@ class MailWebhooksClient:
|
|
|
100
100
|
def retry_delivery(self, delivery_id: str) -> dict[str, Any]:
|
|
101
101
|
return self._client.mail_request("POST", f"/mail/webhook-deliveries/{quote(delivery_id)}/retry")
|
|
102
102
|
|
|
103
|
+
def replay(self, webhook_id: str, *, event_ids: list[str], idempotency_key: str) -> dict[str, Any]:
|
|
104
|
+
return self._client.mail_request(
|
|
105
|
+
"POST",
|
|
106
|
+
f"/mail/webhooks/{quote(webhook_id)}/replay",
|
|
107
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
108
|
+
json={"eventIds": event_ids},
|
|
109
|
+
)
|
|
110
|
+
|
|
103
111
|
def verify_signature(
|
|
104
112
|
self,
|
|
105
113
|
*,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MailExportsClient:
|
|
9
|
+
def __init__(self, client: Any) -> None:
|
|
10
|
+
self._client = client
|
|
11
|
+
|
|
12
|
+
def create(self, *, from_: datetime | None = None, to: datetime | None = None) -> dict[str, Any]:
|
|
13
|
+
payload = {
|
|
14
|
+
"kind": "recipient_delivery",
|
|
15
|
+
"format": "jsonl",
|
|
16
|
+
"from": from_.isoformat() if from_ else None,
|
|
17
|
+
"to": to.isoformat() if to else None,
|
|
18
|
+
}
|
|
19
|
+
return self._client.mail_request(
|
|
20
|
+
"POST", "/mail/exports", json={key: value for key, value in payload.items() if value is not None}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def list(self) -> dict[str, Any]:
|
|
24
|
+
return self._client.mail_request("GET", "/mail/exports")
|
|
25
|
+
|
|
26
|
+
def get(self, export_id: str) -> dict[str, Any]:
|
|
27
|
+
return self._client.mail_request("GET", f"/mail/exports/{quote(export_id)}")
|
|
28
|
+
|
|
29
|
+
def download_path(self, export_id: str) -> str:
|
|
30
|
+
return f"/mail/exports/{quote(export_id)}/download"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MailStreamsClient:
|
|
7
|
+
def __init__(self, client: Any) -> None:
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def create(self, *, name: str, slug: str, type: str) -> dict[str, Any]:
|
|
11
|
+
return self._client.mail_request(
|
|
12
|
+
"POST",
|
|
13
|
+
"/mail/streams",
|
|
14
|
+
json={"name": name, "slug": slug, "type": type},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def list(self) -> dict[str, Any]:
|
|
18
|
+
return self._client.mail_request("GET", "/mail/streams")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stackshift
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Official Python SDK for StackShift.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
|
|
9
|
+
# StackShift Python SDK
|
|
10
|
+
|
|
11
|
+
Official Python SDK for StackShift.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install stackshift
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Send email
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from stackshift import StackShift
|
|
23
|
+
|
|
24
|
+
stackshift = StackShift()
|
|
25
|
+
|
|
26
|
+
message = stackshift.mail.send(
|
|
27
|
+
from_="StackShift <noreply@mail.stackshift.cloud>",
|
|
28
|
+
to="ada@example.com",
|
|
29
|
+
subject="Welcome",
|
|
30
|
+
text="Welcome to StackShift.",
|
|
31
|
+
idempotency_key="welcome:user_123",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
print(message["id"], message["status"], message["idempotencyStatus"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Inspect message status:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
|
|
41
|
+
detail = stackshift.mail.messages.get(messages["data"][0]["id"])
|
|
42
|
+
attempts = stackshift.mail.messages.attempts(detail["id"])
|
|
43
|
+
logs = stackshift.mail.messages.logs(detail["id"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`mta_accepted` means the message was accepted by StackShift's outbound MTA. It does not mean recipient-MX acceptance, inbox placement, opens, clicks, or spam placement. Recipient records later transition independently to `delayed`, `delivered`, `bounced`, `failed`, `suppressed`, or `complained`.
|
|
47
|
+
|
|
48
|
+
## Events and webhooks
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
events = stackshift.mail.events.list(
|
|
52
|
+
type="mail.message.bounced",
|
|
53
|
+
message_id="msg_123",
|
|
54
|
+
limit=20,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
event = stackshift.mail.events.get("evt_123")
|
|
58
|
+
timeline = stackshift.mail.messages.timeline("msg_123")
|
|
59
|
+
|
|
60
|
+
webhook = stackshift.mail.webhooks.create(
|
|
61
|
+
url="https://example.com/stackshift-mail",
|
|
62
|
+
event_types=["mail.message.bounced", "mail.otp.verified"],
|
|
63
|
+
)
|
|
64
|
+
print(webhook["id"], webhook["secret"]) # Secret is only returned on create/rotate.
|
|
65
|
+
|
|
66
|
+
deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
|
|
67
|
+
retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Verify a webhook signature before processing the payload:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
valid = stackshift.mail.webhooks.verify_signature(
|
|
74
|
+
raw_body=request.get_data(),
|
|
75
|
+
signature_header=request.headers.get("StackShift-Signature"),
|
|
76
|
+
timestamp_header=request.headers.get("StackShift-Timestamp"),
|
|
77
|
+
secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
|
|
82
|
+
|
|
83
|
+
## Send a template
|
|
84
|
+
|
|
85
|
+
Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
stackshift.mail.templates.create(
|
|
89
|
+
name="Welcome Email",
|
|
90
|
+
slug="welcome-email",
|
|
91
|
+
subject="Welcome, {{name}}",
|
|
92
|
+
text="Welcome, {{name}}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
preview = stackshift.mail.templates.preview(
|
|
96
|
+
"welcome-email",
|
|
97
|
+
data={"name": "Ada"},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
message = stackshift.mail.send_template(
|
|
101
|
+
template="welcome-email",
|
|
102
|
+
to="ada@example.com",
|
|
103
|
+
from_="Acme <noreply@acme.com>",
|
|
104
|
+
data={"name": "Ada"},
|
|
105
|
+
idempotency_key="welcome:user_123",
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Missing variables fail before a message is queued. Template sends use the same sender-domain, suppression, idempotency, Durable Jobs, and Postfix handoff pipeline as `mail.send`.
|
|
110
|
+
|
|
111
|
+
## Bounces and suppressions
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
suppressions = stackshift.mail.suppressions.list()
|
|
115
|
+
|
|
116
|
+
manual = stackshift.mail.suppressions.create(
|
|
117
|
+
email="bad@example.com",
|
|
118
|
+
reason="manual",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
stackshift.mail.suppressions.delete(manual["id"])
|
|
122
|
+
|
|
123
|
+
bounces = stackshift.mail.bounces.list(type="hard")
|
|
124
|
+
message_bounces = stackshift.mail.messages.bounces("msg_123")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.
|
|
128
|
+
|
|
129
|
+
## Verify a sending domain
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
domain = stackshift.mail.domains.create("acme.com")
|
|
133
|
+
print(domain["records"])
|
|
134
|
+
|
|
135
|
+
stackshift.mail.domains.verify(domain["id"])
|
|
136
|
+
|
|
137
|
+
message = stackshift.mail.send(
|
|
138
|
+
from_="Acme <noreply@acme.com>",
|
|
139
|
+
to="user@example.com",
|
|
140
|
+
subject="Welcome",
|
|
141
|
+
html="<h1>Welcome</h1>",
|
|
142
|
+
text="Welcome",
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Add the returned SPF, DKIM, and return-path DNS records before verification. DMARC is recommended unless your environment sets `MAIL_DMARC_REQUIRED=true`. DNS propagation can take time.
|
|
147
|
+
|
|
148
|
+
## Upload an asset
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from stackshift import StackShift
|
|
152
|
+
|
|
153
|
+
stackshift = StackShift()
|
|
154
|
+
|
|
155
|
+
asset = stackshift.assets.upload(
|
|
156
|
+
"avatar.png",
|
|
157
|
+
folder="avatars",
|
|
158
|
+
visibility="public",
|
|
159
|
+
metadata={"user_id": "user_123"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
print(asset["url"])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.
|
|
166
|
+
|
|
167
|
+
## Private asset URL
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
signed = stackshift.assets.signed_url(
|
|
171
|
+
asset["id"],
|
|
172
|
+
expires_in="10m",
|
|
173
|
+
max_downloads=1,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
print(signed["url"])
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Image transformations
|
|
180
|
+
|
|
181
|
+
Use built-in presets or create your own named transformations.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from stackshift import StackShift, asset_transform_options, get_asset_transform_preset
|
|
185
|
+
|
|
186
|
+
stackshift = StackShift()
|
|
187
|
+
hero = get_asset_transform_preset("hero")
|
|
188
|
+
hero_options = asset_transform_options(hero)
|
|
189
|
+
|
|
190
|
+
stackshift.assets.create_transformation(hero["name"], **hero_options)
|
|
191
|
+
|
|
192
|
+
named = stackshift.assets.named_url("asset_123", hero["name"])
|
|
193
|
+
signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")
|
|
194
|
+
|
|
195
|
+
stackshift.assets.delete_transformation("old-preset")
|
|
196
|
+
print(named, signed["url"])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Direct browser uploads
|
|
200
|
+
|
|
201
|
+
Create the upload session on your Python backend:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
upload = stackshift.assets.signed_upload_url(
|
|
205
|
+
bucket="avatars",
|
|
206
|
+
key="users/user_123.png",
|
|
207
|
+
visibility="public",
|
|
208
|
+
expiresIn="10m",
|
|
209
|
+
maxBytes=5_000_000,
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.
|
|
214
|
+
|
|
215
|
+
The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.
|
|
@@ -2,17 +2,24 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
stackshift/__init__.py
|
|
4
4
|
stackshift/assets.py
|
|
5
|
+
stackshift/assets_workflows.py
|
|
5
6
|
stackshift/client.py
|
|
6
7
|
stackshift/mail.py
|
|
7
8
|
stackshift/mail_advanced.py
|
|
8
|
-
stackshift/
|
|
9
|
+
stackshift/mail_campaigns.py
|
|
10
|
+
stackshift/mail_events_webhooks.py
|
|
11
|
+
stackshift/mail_exports.py
|
|
9
12
|
stackshift/mail_reputation.py
|
|
13
|
+
stackshift/mail_streams.py
|
|
10
14
|
stackshift/projects.py
|
|
11
15
|
stackshift.egg-info/PKG-INFO
|
|
12
16
|
stackshift.egg-info/SOURCES.txt
|
|
13
17
|
stackshift.egg-info/dependency_links.txt
|
|
14
18
|
stackshift.egg-info/requires.txt
|
|
15
19
|
stackshift.egg-info/top_level.txt
|
|
20
|
+
tests/test_assets_workflows.py
|
|
16
21
|
tests/test_mail.py
|
|
17
22
|
tests/test_mail_advanced.py
|
|
18
|
-
tests/
|
|
23
|
+
tests/test_mail_diagnostics.py
|
|
24
|
+
tests/test_mail_events_webhooks.py
|
|
25
|
+
tests/test_mail_exports.py
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from stackshift.assets import AssetsClient
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FakeClient:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.calls = []
|
|
7
|
+
|
|
8
|
+
def request(self, method, path, **kwargs):
|
|
9
|
+
self.calls.append((method, path, kwargs))
|
|
10
|
+
return {"method": method, "path": path}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_transform_generation_and_workflow_routes():
|
|
14
|
+
transport = FakeClient()
|
|
15
|
+
assets = AssetsClient(transport)
|
|
16
|
+
|
|
17
|
+
assets.materialize_transform(
|
|
18
|
+
"asset/1", preset="web-ready", idempotency_key="render-1"
|
|
19
|
+
)
|
|
20
|
+
method, path, options = transport.calls[-1]
|
|
21
|
+
assert (method, path) == ("POST", "/assets/asset%2F1/transforms")
|
|
22
|
+
assert options["json"] == {"preset": "web-ready"}
|
|
23
|
+
assert options["headers"]["Idempotency-Key"] == "render-1"
|
|
24
|
+
|
|
25
|
+
assets.generate(
|
|
26
|
+
{"prompt": "A violet nebula", "size": "1024x1024", "format": "png", "bucket": "generated"},
|
|
27
|
+
idempotency_key="generation-1",
|
|
28
|
+
)
|
|
29
|
+
assert transport.calls[-1][2]["headers"]["Idempotency-Key"] == "generation-1"
|
|
30
|
+
|
|
31
|
+
assets.publish_workflow("workflow/1", 4)
|
|
32
|
+
method, path, options = transport.calls[-1]
|
|
33
|
+
assert (method, path) == ("POST", "/assets/workflows/workflow%2F1/publish")
|
|
34
|
+
assert options["json"] == {"expected_revision": 4}
|
|
@@ -96,9 +96,9 @@ class FakeSession:
|
|
|
96
96
|
if url.endswith("/mail/bounces"):
|
|
97
97
|
return FakeResponse(200, {"data": [bounce_response()], "nextCursor": None})
|
|
98
98
|
if url.endswith("/mail/messages/msg_123"):
|
|
99
|
-
return FakeResponse(200, {"id": "msg_123", "from": {"email": "noreply@mail.stackshift.cloud"}, "to": ["ada@example.com"], "cc": [], "bcc": [], "subject": "Welcome", "status": "
|
|
99
|
+
return FakeResponse(200, {"id": "msg_123", "from": {"email": "noreply@mail.stackshift.cloud"}, "to": ["ada@example.com"], "cc": [], "bcc": [], "subject": "Welcome", "status": "mta_accepted"})
|
|
100
100
|
if url.endswith("/mail/messages"):
|
|
101
|
-
return FakeResponse(200, {"data": [{"id": "msg_123", "status": "
|
|
101
|
+
return FakeResponse(200, {"data": [{"id": "msg_123", "status": "mta_accepted"}], "nextCursor": None})
|
|
102
102
|
if url.endswith("/assets/transformations") and method == "GET":
|
|
103
103
|
return FakeResponse(200, {"success": True, "data": {"transformations": [{"id": "tr_1", "name": "custom-card", "normalized_spec": "w_640,h_360,c_fill,f_webp,q_82"}]}})
|
|
104
104
|
if url.endswith("/assets/transformations") and method == "POST":
|
|
@@ -152,12 +152,12 @@ class MailSDKTest(unittest.TestCase):
|
|
|
152
152
|
self.assertEqual(session.calls[0][1], "https://api.stackshift.cloud/v1/mail/send")
|
|
153
153
|
self.assertEqual(session.calls[0][2]["headers"]["Authorization"], "Bearer sk_test")
|
|
154
154
|
|
|
155
|
-
messages = stackshift.mail.messages.list(status="
|
|
156
|
-
self.assertEqual(messages["data"][0]["status"], "
|
|
157
|
-
self.assertEqual(session.calls[1][2]["params"]["status"], "
|
|
155
|
+
messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
|
|
156
|
+
self.assertEqual(messages["data"][0]["status"], "mta_accepted")
|
|
157
|
+
self.assertEqual(session.calls[1][2]["params"]["status"], "mta_accepted")
|
|
158
158
|
|
|
159
159
|
message = stackshift.mail.messages.get("msg_123")
|
|
160
|
-
self.assertEqual(message["status"], "
|
|
160
|
+
self.assertEqual(message["status"], "mta_accepted")
|
|
161
161
|
|
|
162
162
|
attempts = stackshift.mail.messages.attempts("msg_123")
|
|
163
163
|
self.assertEqual(attempts["data"][0]["status"], "accepted_by_mta")
|
|
@@ -312,7 +312,7 @@ def domain_response(status):
|
|
|
312
312
|
{
|
|
313
313
|
"type": "TXT",
|
|
314
314
|
"name": "acme.com",
|
|
315
|
-
"value": "v=spf1 include:_spf.stackshift.
|
|
315
|
+
"value": "v=spf1 include:_spf.stackshift.cloud ~all",
|
|
316
316
|
"status": "verified",
|
|
317
317
|
"required": True,
|
|
318
318
|
}
|
|
@@ -39,6 +39,12 @@ class FakeSession:
|
|
|
39
39
|
return FakeResponse(202, {"id": "batch_tpl_123", "status": "queued", "messageCount": 1})
|
|
40
40
|
if url.endswith("/mail/batch"):
|
|
41
41
|
return FakeResponse(202, {"id": "batch_123", "status": "queued", "messageCount": 1})
|
|
42
|
+
if url.endswith("/mail/batches/batch_123/items"):
|
|
43
|
+
return FakeResponse(200, {"data": [{"id": "item_123", "recipients": [{"email": "ada@example.com", "status": "delivered"}]}]})
|
|
44
|
+
if url.endswith("/mail/batches/batch_123"):
|
|
45
|
+
return FakeResponse(200, {"id": "batch_123", "status": "completed", "messageCount": 1})
|
|
46
|
+
if url.endswith("/mail/batches"):
|
|
47
|
+
return FakeResponse(200, {"data": [{"id": "batch_123", "status": "completed", "messageCount": 1}]})
|
|
42
48
|
if url.endswith("/mail/inbound/domains") and method == "POST":
|
|
43
49
|
return FakeResponse(201, {"id": "ind_123", "domain": "inbound.example.com", "status": "pending"})
|
|
44
50
|
if url.endswith("/mail/inbound/domains"):
|
|
@@ -50,7 +56,7 @@ class FakeSession:
|
|
|
50
56
|
if url.endswith("/mail/inbound/messages"):
|
|
51
57
|
return FakeResponse(200, {"data": [{"id": "inm_123", "fromEmail": "ada@example.com", "toEmails": ["support@example.com"]}]})
|
|
52
58
|
if url.endswith("/mail/analytics"):
|
|
53
|
-
return FakeResponse(200, {"summary": {"
|
|
59
|
+
return FakeResponse(200, {"summary": {"mtaAccepted": 1}, "series": [{"bucket": "2026-05-11", "mtaAccepted": 1}]})
|
|
54
60
|
return FakeResponse(404, {"error": {"code": "not_found", "message": "Not found."}})
|
|
55
61
|
|
|
56
62
|
|
|
@@ -80,6 +86,9 @@ class MailAdvancedSDKTest(unittest.TestCase):
|
|
|
80
86
|
recipients=[{"to": "ada@example.com", "data": {"name": "Ada"}}],
|
|
81
87
|
)
|
|
82
88
|
self.assertEqual(template_batch["id"], "batch_tpl_123")
|
|
89
|
+
self.assertEqual(stackshift.mail.batch.list()["data"][0]["id"], "batch_123")
|
|
90
|
+
self.assertEqual(stackshift.mail.batch.get("batch_123")["status"], "completed")
|
|
91
|
+
self.assertEqual(stackshift.mail.batch.items("batch_123")["data"][0]["recipients"][0]["status"], "delivered")
|
|
83
92
|
|
|
84
93
|
self.assertEqual(stackshift.mail.inbound.domains.create("inbound.example.com")["id"], "ind_123")
|
|
85
94
|
self.assertEqual(stackshift.mail.inbound.domains.list()["data"][0]["status"], "verified")
|
|
@@ -88,7 +97,7 @@ class MailAdvancedSDKTest(unittest.TestCase):
|
|
|
88
97
|
self.assertEqual(stackshift.mail.inbound.messages.get("inm_123")["fromEmail"], "ada@example.com")
|
|
89
98
|
|
|
90
99
|
analytics = stackshift.mail.analytics.get(range_="7d", interval="day", domain="example.com")
|
|
91
|
-
self.assertEqual(analytics["summary"]["
|
|
100
|
+
self.assertEqual(analytics["summary"]["mtaAccepted"], 1)
|
|
92
101
|
self.assertEqual(session.calls[-1][2]["params"]["domain"], "example.com")
|
|
93
102
|
|
|
94
103
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from stackshift.mail import MailTemplatesClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeClient:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.calls = []
|
|
9
|
+
|
|
10
|
+
def mail_request(self, method, path, **kwargs):
|
|
11
|
+
self.calls.append((method, path, kwargs))
|
|
12
|
+
return {"status": "warning", "findings": [{"code": "remote_images"}]}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MailDiagnosticsTest(unittest.TestCase):
|
|
16
|
+
def test_template_diagnostics(self):
|
|
17
|
+
raw = FakeClient()
|
|
18
|
+
result = MailTemplatesClient(raw).diagnostics("tpl_123", stream="broadcast")
|
|
19
|
+
|
|
20
|
+
self.assertEqual(result["findings"][0]["code"], "remote_images")
|
|
21
|
+
self.assertEqual(raw.calls[0][0:2], ("POST", "/mail/templates/tpl_123/diagnostics"))
|
|
22
|
+
self.assertEqual(raw.calls[0][2]["json"]["stream"], "broadcast")
|
stackshift-0.1.2/tests/test_mail_phase7.py → stackshift-1.0.1/tests/test_mail_events_webhooks.py
RENAMED
|
@@ -34,7 +34,7 @@ class FakeSession:
|
|
|
34
34
|
if url.endswith("/mail/events/evt_123"):
|
|
35
35
|
return FakeResponse(200, event_response())
|
|
36
36
|
if url.endswith("/mail/messages/msg_123/timeline"):
|
|
37
|
-
return FakeResponse(200, {"data": [{"type": "mail.message.
|
|
37
|
+
return FakeResponse(200, {"data": [{"type": "mail.message.mta_accepted", "label": "Accepted by StackShift outbound MTA"}]})
|
|
38
38
|
if url.endswith("/mail/webhooks") and method == "POST":
|
|
39
39
|
payload = webhook_response()
|
|
40
40
|
payload["secret"] = "whsec_test"
|
|
@@ -60,22 +60,22 @@ class FakeSession:
|
|
|
60
60
|
return FakeResponse(404, {"error": {"code": "not_found", "message": "Not found."}})
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
class
|
|
63
|
+
class MailEventsWebhooksSDKTest(unittest.TestCase):
|
|
64
64
|
def test_events_timeline_and_webhooks(self):
|
|
65
65
|
session = FakeSession()
|
|
66
66
|
stackshift = StackShift(api_key="sk_test", session=session)
|
|
67
67
|
|
|
68
|
-
events = stackshift.mail.events.list(type="mail.message.
|
|
68
|
+
events = stackshift.mail.events.list(type="mail.message.mta_accepted", message_id="msg_123", limit=20)
|
|
69
69
|
self.assertEqual(events["data"][0]["id"], "evt_123")
|
|
70
70
|
self.assertEqual(session.calls[0][2]["params"]["messageId"], "msg_123")
|
|
71
71
|
|
|
72
72
|
event = stackshift.mail.events.get("evt_123")
|
|
73
|
-
self.assertEqual(event["type"], "mail.message.
|
|
73
|
+
self.assertEqual(event["type"], "mail.message.mta_accepted")
|
|
74
74
|
|
|
75
75
|
timeline = stackshift.mail.messages.timeline("msg_123")
|
|
76
76
|
self.assertEqual(timeline["data"][0]["label"], "Accepted by StackShift outbound MTA")
|
|
77
77
|
|
|
78
|
-
created = stackshift.mail.webhooks.create(url="https://example.com/hook", event_types=["mail.message.
|
|
78
|
+
created = stackshift.mail.webhooks.create(url="https://example.com/hook", event_types=["mail.message.mta_accepted"])
|
|
79
79
|
self.assertEqual(created["secret"], "whsec_test")
|
|
80
80
|
|
|
81
81
|
webhooks = stackshift.mail.webhooks.list(limit=10)
|
|
@@ -140,9 +140,9 @@ class MailPhase7SDKTest(unittest.TestCase):
|
|
|
140
140
|
def event_response():
|
|
141
141
|
return {
|
|
142
142
|
"id": "evt_123",
|
|
143
|
-
"type": "mail.message.
|
|
143
|
+
"type": "mail.message.mta_accepted",
|
|
144
144
|
"messageId": "msg_123",
|
|
145
|
-
"payload": {"messageId": "msg_123", "status": "
|
|
145
|
+
"payload": {"messageId": "msg_123", "status": "mta_accepted"},
|
|
146
146
|
"occurredAt": "2026-05-09T12:00:00Z",
|
|
147
147
|
"createdAt": "2026-05-09T12:00:00Z",
|
|
148
148
|
}
|
|
@@ -154,7 +154,7 @@ def webhook_response():
|
|
|
154
154
|
"url": "https://example.com/hook",
|
|
155
155
|
"description": None,
|
|
156
156
|
"status": "active",
|
|
157
|
-
"eventTypes": ["mail.message.
|
|
157
|
+
"eventTypes": ["mail.message.mta_accepted"],
|
|
158
158
|
"createdAt": "2026-05-09T12:00:00Z",
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from stackshift.mail_exports import MailExportsClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeClient:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.calls = []
|
|
10
|
+
|
|
11
|
+
def mail_request(self, method, path, **kwargs):
|
|
12
|
+
self.calls.append((method, path, kwargs))
|
|
13
|
+
return {"id": "exp_123", "status": "queued"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MailExportsTest(unittest.TestCase):
|
|
17
|
+
def test_create_list_get_and_download_path(self):
|
|
18
|
+
raw = FakeClient()
|
|
19
|
+
exports = MailExportsClient(raw)
|
|
20
|
+
created = exports.create(from_=datetime(2026, 8, 1, tzinfo=timezone.utc))
|
|
21
|
+
exports.list()
|
|
22
|
+
exports.get("exp_123")
|
|
23
|
+
|
|
24
|
+
self.assertEqual(created["id"], "exp_123")
|
|
25
|
+
self.assertEqual(raw.calls[0][2]["json"]["kind"], "recipient_delivery")
|
|
26
|
+
self.assertEqual(exports.download_path("exp_123"), "/mail/exports/exp_123/download")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
unittest.main()
|
stackshift-0.1.2/PKG-INFO
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|