stackshift 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stackshift/__init__.py CHANGED
@@ -1,5 +1,33 @@
1
1
  from .client import StackShift
2
- from .assets import AssetsClient
2
+ from .assets import ASSET_TRANSFORM_PRESETS, AssetsClient, asset_transform_options, asset_transform_spec, get_asset_transform_preset
3
+ from .mail import (
4
+ MailClient,
5
+ MailDomainsClient,
6
+ MailOTPChallengesClient,
7
+ MailOTPClient,
8
+ MailTemplatesClient,
9
+ )
10
+ from .mail_reputation import MailLimitsClient, MailReputationClient, MailReputationEventsClient
11
+ from .mail_phase7 import MailEventsClient, MailWebhooksClient, verify_webhook_signature
3
12
  from .projects import ProjectsClient
4
13
 
5
- __all__ = ["StackShift", "AssetsClient", "ProjectsClient"]
14
+ __all__ = [
15
+ "StackShift",
16
+ "ASSET_TRANSFORM_PRESETS",
17
+ "AssetsClient",
18
+ "MailClient",
19
+ "MailDomainsClient",
20
+ "MailEventsClient",
21
+ "MailLimitsClient",
22
+ "MailOTPClient",
23
+ "MailOTPChallengesClient",
24
+ "MailReputationClient",
25
+ "MailReputationEventsClient",
26
+ "MailTemplatesClient",
27
+ "MailWebhooksClient",
28
+ "ProjectsClient",
29
+ "asset_transform_options",
30
+ "asset_transform_spec",
31
+ "get_asset_transform_preset",
32
+ "verify_webhook_signature",
33
+ ]
stackshift/assets.py CHANGED
@@ -1,8 +1,50 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import hashlib
4
5
  from pathlib import Path
5
6
  from typing import Any, BinaryIO
7
+ from urllib.parse import quote
8
+
9
+
10
+ ASSET_TRANSFORM_PRESETS: list[dict[str, Any]] = [
11
+ {"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},
12
+ {"id": "web-card", "name": "web-card", "category": "responsive-web", "description": "Balanced card image for product and content listings.", "width": 640, "height": 360, "crop": "fill", "format": "webp", "quality": 82},
13
+ {"id": "feature-image", "name": "feature-image", "category": "responsive-web", "description": "Wide content feature image with enough detail for retina screens.", "width": 1200, "height": 675, "crop": "fill", "format": "webp", "quality": 84},
14
+ {"id": "hero", "name": "hero", "category": "responsive-web", "description": "Large hero image for landing pages and page headers.", "width": 1600, "height": 900, "crop": "fill", "format": "webp", "quality": 84},
15
+ {"id": "full-width-banner", "name": "full-width-banner", "category": "responsive-web", "description": "Extra-wide banner for app shells and marketing sections.", "width": 1920, "height": 720, "crop": "fill", "format": "webp", "quality": 82},
16
+ {"id": "open-graph", "name": "open-graph", "category": "social", "description": "Share preview image for Open Graph cards.", "width": 1200, "height": 630, "crop": "fill", "format": "jpeg", "quality": 85},
17
+ {"id": "square-post", "name": "square-post", "category": "social", "description": "Square image for social feeds.", "width": 1080, "height": 1080, "crop": "fill", "format": "webp", "quality": 84},
18
+ {"id": "story", "name": "story", "category": "social", "description": "Vertical story format for mobile social placements.", "width": 1080, "height": 1920, "crop": "fill", "format": "webp", "quality": 82},
19
+ {"id": "x-twitter-card", "name": "x-twitter-card", "category": "social", "description": "Landscape card optimized for X and Twitter previews.", "width": 1200, "height": 675, "crop": "fill", "format": "jpeg", "quality": 84},
20
+ {"id": "linkedin-share", "name": "linkedin-share", "category": "social", "description": "LinkedIn share preview dimensions.", "width": 1200, "height": 627, "crop": "fill", "format": "jpeg", "quality": 84},
21
+ {"id": "product-square", "name": "product-square", "category": "product", "description": "Square product image with clean containment.", "width": 1024, "height": 1024, "crop": "fit", "format": "webp", "quality": 86},
22
+ {"id": "product-card", "name": "product-card", "category": "product", "description": "Product listing image for commerce grids.", "width": 640, "height": 640, "crop": "fit", "format": "webp", "quality": 84},
23
+ {"id": "product-zoom", "name": "product-zoom", "category": "product", "description": "High-detail product image for zoom interactions.", "width": 2000, "height": 2000, "crop": "fit", "format": "jpeg", "quality": 90},
24
+ {"id": "gallery-thumb", "name": "gallery-thumb", "category": "product", "description": "Compact gallery thumbnail for product detail pages.", "width": 400, "height": 400, "crop": "fill", "format": "webp", "quality": 82},
25
+ {"id": "avatar-small", "name": "avatar-small", "category": "avatar-profile", "description": "Small circular avatar source for dense UI.", "width": 128, "height": 128, "crop": "fill", "format": "webp", "quality": 82},
26
+ {"id": "avatar-retina", "name": "avatar-retina", "category": "avatar-profile", "description": "Retina avatar source for account and profile UI.", "width": 256, "height": 256, "crop": "fill", "format": "webp", "quality": 84},
27
+ {"id": "profile-cover", "name": "profile-cover", "category": "avatar-profile", "description": "Wide profile cover image.", "width": 1500, "height": 500, "crop": "fill", "format": "webp", "quality": 82},
28
+ {"id": "webp-auto", "name": "webp-auto", "category": "performance", "description": "Auto-sized WebP delivery with browser-friendly compression.", "width": 1280, "crop": "fit", "format": "webp", "quality": 78},
29
+ {"id": "avif-compressed", "name": "avif-compressed", "category": "performance", "description": "Highly compressed AVIF for modern browsers.", "width": 1280, "crop": "fit", "format": "avif", "quality": 62},
30
+ {"id": "lqip", "name": "lqip", "category": "performance", "description": "Tiny low-quality placeholder for progressive image loading.", "width": 48, "crop": "fit", "format": "jpeg", "quality": 35},
31
+ {"id": "blur-placeholder", "name": "blur-placeholder", "category": "performance", "description": "Small placeholder candidate for blurred loading states.", "width": 32, "height": 32, "crop": "fill", "format": "jpeg", "quality": 30},
32
+ {"id": "email-safe-600", "name": "email-safe-600", "category": "email-docs", "description": "Email-safe content image with conservative width.", "width": 600, "crop": "fit", "format": "jpeg", "quality": 82},
33
+ {"id": "markdown-image", "name": "markdown-image", "category": "email-docs", "description": "Documentation image that fits most reading columns.", "width": 800, "crop": "fit", "format": "webp", "quality": 82},
34
+ {"id": "docs-screenshot", "name": "docs-screenshot", "category": "email-docs", "description": "Crisp screenshot size for guides and changelogs.", "width": 1440, "height": 900, "crop": "fit", "format": "webp", "quality": 84},
35
+ ]
36
+
37
+
38
+ def get_asset_transform_preset(name: str) -> dict[str, Any] | None:
39
+ return next((preset for preset in ASSET_TRANSFORM_PRESETS if preset["name"] == name), None)
40
+
41
+
42
+ def asset_transform_options(preset: dict[str, Any]) -> dict[str, Any]:
43
+ return {key: preset[key] for key in ("width", "height", "crop", "format", "quality") if key in preset}
44
+
45
+
46
+ def asset_transform_spec(**options: Any) -> str:
47
+ return _transform_spec(options)
6
48
 
7
49
 
8
50
  class AssetsClient:
@@ -25,7 +67,7 @@ class AssetsClient:
25
67
  "key": key,
26
68
  "folder": folder,
27
69
  "visibility": visibility,
28
- "cacheControl": cache_control,
70
+ "cache_control": cache_control,
29
71
  "metadata": json.dumps(metadata) if metadata else None,
30
72
  })
31
73
 
@@ -36,21 +78,25 @@ class AssetsClient:
36
78
  def get(self, asset_id: str) -> dict[str, Any]:
37
79
  return self._client.request("GET", f"/assets/{asset_id}")
38
80
 
39
- def delete(self, asset_id: str) -> dict[str, Any]:
40
- return self._client.request("DELETE", f"/assets/{asset_id}")
81
+ def delete(self, asset_id: str, revision: int, *, idempotency_key: str | None = None) -> dict[str, Any]:
82
+ headers = {"If-Match": f'"{revision}"'}
83
+ if idempotency_key:
84
+ headers["Idempotency-Key"] = idempotency_key
85
+ return self._client.request("DELETE", f"/assets/{asset_id}", headers=headers)
41
86
 
42
87
  def replace(
43
88
  self,
44
89
  asset_id: str,
90
+ revision: int,
45
91
  file: str | Path | BinaryIO,
46
92
  *,
47
93
  cache_control: str | None = None,
48
94
  metadata: dict[str, Any] | None = None,
49
95
  ) -> dict[str, Any]:
50
96
  return self._multipart("PUT", f"/assets/{asset_id}/replace", file, {
51
- "cacheControl": cache_control,
97
+ "cache_control": cache_control,
52
98
  "metadata": json.dumps(metadata) if metadata else None,
53
- })
99
+ }, headers={"If-Match": f'"{revision}"'})
54
100
 
55
101
  def signed_url(
56
102
  self,
@@ -60,18 +106,259 @@ class AssetsClient:
60
106
  max_downloads: int | None = None,
61
107
  ) -> dict[str, Any]:
62
108
  return self._client.request("POST", f"/assets/{asset_id}/signed-url", json={
63
- "expiresIn": expires_in,
64
- "maxDownloads": max_downloads,
109
+ "expires_in": expires_in,
110
+ "max_downloads": max_downloads,
65
111
  })
66
112
 
67
113
  def signed_upload_url(self, **payload: Any) -> dict[str, Any]:
68
114
  return self.create_upload_session(**payload)
69
115
 
70
116
  def create_upload_session(self, **payload: Any) -> dict[str, Any]:
117
+ idempotency_key = payload.pop("idempotency_key", None)
71
118
  clean = {key: value for key, value in payload.items() if value not in (None, "")}
72
- return self._client.request("POST", "/assets/upload-sessions", json=clean)
119
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
120
+ return self._client.request("POST", "/assets/upload-sessions", json=clean, headers=headers)
121
+
122
+ def create_chunked_upload_session(self, **payload: Any) -> dict[str, Any]:
123
+ return self.create_upload_session(mode="chunked", **payload)
124
+
125
+ def upload_chunk(
126
+ self,
127
+ upload_url_or_token: str,
128
+ part_number: int,
129
+ chunk: bytes | BinaryIO,
130
+ ) -> dict[str, Any]:
131
+ target = upload_url_or_token.rstrip("/")
132
+ if not target.startswith("http"):
133
+ origin = self._client.base_url.removesuffix("/api/v1")
134
+ target = f"{origin}/uploads/{quote(upload_url_or_token)}"
135
+
136
+ raw = chunk if isinstance(chunk, bytes) else chunk.read()
137
+ response = self._client.session.put(
138
+ f"{target}/parts/{part_number}",
139
+ data=raw,
140
+ headers={"User-Agent": "StackShift-Python/0.1", "X-Content-SHA256": hashlib.sha256(raw).hexdigest()},
141
+ )
142
+ payload = response.json() if response.content else None
143
+ if response.status_code < 200 or response.status_code >= 300:
144
+ message = payload.get("message") if isinstance(payload, dict) else None
145
+ raise RuntimeError(message or f"StackShift upload failed with {response.status_code}")
146
+ return payload["data"] if isinstance(payload, dict) and "data" in payload else payload
147
+
148
+ def resume_upload_session(self, session_id: str) -> dict[str, Any]:
149
+ return self._client.request("GET", f"/assets/upload-sessions/{session_id}")
150
+
151
+ def complete_upload_session(self, session_id: str) -> dict[str, Any]:
152
+ return self._client.request("POST", f"/assets/upload-sessions/{session_id}/complete")
153
+
154
+ def cancel_upload_session(self, session_id: str) -> dict[str, Any]:
155
+ return self._client.request("DELETE", f"/assets/upload-sessions/{session_id}")
156
+
157
+ def list_buckets(self) -> dict[str, Any]:
158
+ return self._client.request("GET", "/assets/buckets")
159
+
160
+ def create_bucket(self, **payload: Any) -> dict[str, Any]:
161
+ return self._client.request("POST", "/assets/buckets", json=payload)
162
+
163
+ def update_bucket(self, bucket_id: str, revision: int, **payload: Any) -> dict[str, Any]:
164
+ return self._client.request("PUT", f"/assets/buckets/{bucket_id}", json=payload, headers={"If-Match": f'"{revision}"'})
165
+
166
+ def delete_bucket(self, bucket_id: str, revision: int) -> dict[str, Any]:
167
+ return self._client.request("DELETE", f"/assets/buckets/{bucket_id}", headers={"If-Match": f'"{revision}"'})
168
+
169
+ def set_tags(self, asset_id: str, revision: int, tags: list[str]) -> dict[str, Any]:
170
+ return self._client.request("PUT", f"/assets/{asset_id}/tags", json={"tags": tags}, headers={"If-Match": f'"{revision}"'})
171
+
172
+ def patch_metadata(self, asset_id: str, revision: int, metadata: dict[str, Any]) -> dict[str, Any]:
173
+ return self._client.request("PATCH", f"/assets/{asset_id}/metadata", json={"metadata": metadata}, headers={"If-Match": f'"{revision}"'})
174
+
175
+ def patch(self, asset_id: str, revision: int, **payload: Any) -> dict[str, Any]:
176
+ return self._client.request("PATCH", f"/assets/{asset_id}", json=payload, headers={"If-Match": f'"{revision}"'})
177
+
178
+ def url(self, asset_id: str, **options: Any) -> str:
179
+ spec = _transform_spec(options)
180
+ return f"{self._client.cdn_base_url}/assets/{quote(asset_id)}/tr/{quote(spec)}"
181
+
182
+ def named_url(self, asset_id: str, name: str) -> str:
183
+ return f"{self._client.cdn_base_url}/t/{quote(name)}/assets/{quote(asset_id)}"
184
+
185
+ def version_url(self, asset_id: str, version_id: str, token: str | None = None) -> str:
186
+ prefix = "/private/assets" if token else "/assets"
187
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/versions/{quote(version_id)}"
188
+ return f"{result}?token={quote(token)}" if token else result
189
+
190
+ def branch_url(self, asset_id: str, branch: str, token: str | None = None) -> str:
191
+ prefix = "/private/assets" if token else "/assets"
192
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/branches/{quote(branch)}"
193
+ return f"{result}?token={quote(token)}" if token else result
194
+
195
+ def video_url(self, asset_id: str, kind: str, profile: str = "default", token: str | None = None) -> str:
196
+ prefix = "/private/assets" if token else "/assets"
197
+ result = f"{self._client.cdn_base_url}{prefix}/{quote(asset_id)}/video/{quote(kind)}/{quote(profile)}"
198
+ return f"{result}?token={quote(token)}" if token else result
199
+
200
+ def ai(self, asset_id: str) -> dict[str, Any]:
201
+ return self._client.request("GET", f"/assets/{asset_id}/ai")
202
+
203
+ def request_ai_analyze(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
204
+ return self._request_ai_job(f"/assets/{asset_id}/ai/analyze", idempotency_key)
205
+
206
+ def request_moderation(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
207
+ return self._request_ai_job(f"/assets/{asset_id}/moderation", idempotency_key)
208
+
209
+ def transcript(self, asset_id: str) -> dict[str, Any]:
210
+ return self._client.request("GET", f"/assets/{asset_id}/transcript")
211
+
212
+ def request_transcript(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
213
+ return self._request_ai_job(f"/assets/{asset_id}/transcript", idempotency_key)
214
+
215
+ def request_smart_crop(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
216
+ return self._request_ai_job(f"/assets/{asset_id}/smart-crop", idempotency_key)
217
+
218
+ def request_background_removal(self, asset_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
219
+ return self._request_ai_job(f"/assets/{asset_id}/background-remove", idempotency_key)
220
+
221
+ def _request_ai_job(self, path: str, idempotency_key: str | None) -> dict[str, Any]:
222
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
223
+ return self._client.request("POST", path, headers=headers)
224
+
225
+ def versions(self, asset_id: str) -> dict[str, Any]:
226
+ return self._client.request("GET", f"/assets/{asset_id}/versions")
227
+
228
+ def create_branch(
229
+ self,
230
+ asset_id: str,
231
+ name: str,
232
+ from_version_id: str | None = None,
233
+ ) -> dict[str, Any]:
234
+ payload = {"name": name, "from_version_id": from_version_id}
235
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
236
+ return self._client.request("POST", f"/assets/{asset_id}/branches", json=clean)
237
+
238
+ def restore_version(self, asset_id: str, version_id: str) -> dict[str, Any]:
239
+ return self._client.request("POST", f"/assets/{asset_id}/versions/{version_id}/restore")
240
+
241
+ def promote_branch(self, asset_id: str, branch: str) -> dict[str, Any]:
242
+ return self._client.request("POST", f"/assets/{asset_id}/branches/{quote(branch)}/promote")
73
243
 
74
- def _multipart(self, method: str, suffix: str, file: str | Path | BinaryIO, fields: dict[str, Any]) -> dict[str, Any]:
244
+ def collections(self) -> dict[str, Any]:
245
+ return self._client.request("GET", "/assets/collections")
246
+
247
+ def create_collection(self, **payload: Any) -> dict[str, Any]:
248
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
249
+ return self._client.request("POST", "/assets/collections", json=clean)
250
+
251
+ def update_collection(self, collection_id: str, **payload: Any) -> dict[str, Any]:
252
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
253
+ return self._client.request("PUT", f"/assets/collections/{collection_id}", json=clean)
254
+
255
+ def delete_collection(self, collection_id: str) -> dict[str, Any]:
256
+ return self._client.request("DELETE", f"/assets/collections/{collection_id}")
257
+
258
+ def saved_searches(self) -> dict[str, Any]:
259
+ return self._client.request("GET", "/assets/saved-searches")
260
+
261
+ def save_search(self, **payload: Any) -> dict[str, Any]:
262
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
263
+ return self._client.request("POST", "/assets/saved-searches", json=clean)
264
+
265
+ def update_saved_search(self, search_id: str, **payload: Any) -> dict[str, Any]:
266
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
267
+ return self._client.request("PUT", f"/assets/saved-searches/{search_id}", json=clean)
268
+
269
+ def delete_saved_search(self, search_id: str) -> dict[str, Any]:
270
+ return self._client.request("DELETE", f"/assets/saved-searches/{search_id}")
271
+
272
+ def get_policy(self) -> dict[str, Any]:
273
+ return self._client.request("GET", "/assets/policy")
274
+
275
+ def update_policy(self, **payload: Any) -> dict[str, Any]:
276
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
277
+ return self._client.request("PUT", "/assets/policy", json=clean)
278
+
279
+ def create_transformation(self, name: str, **options: Any) -> dict[str, Any]:
280
+ payload = {"name": name, **options}
281
+ clean = {key: value for key, value in payload.items() if value not in (None, "")}
282
+ return self._client.request("POST", "/assets/transformations", json=clean)
283
+
284
+ def list_transformations(self) -> dict[str, Any]:
285
+ return self._client.request("GET", "/assets/transformations")
286
+
287
+ def delete_transformation(self, name: str) -> dict[str, Any]:
288
+ return self._client.request("DELETE", f"/assets/transformations/{quote(name)}")
289
+
290
+ def signed_transform_url(self, asset_id: str, **options: Any) -> dict[str, Any]:
291
+ clean = {key: value for key, value in options.items() if value not in (None, "")}
292
+ return self._client.request("POST", f"/assets/{asset_id}/transform-url", json=clean)
293
+
294
+ def get_ai_config(self) -> dict[str, Any]:
295
+ return self._client.request("GET", "/assets/ai/config")
296
+
297
+ def update_ai_config(self, revision: int, *, enabled_actions: list[str], monthly_spend_cap_micros: int) -> dict[str, Any]:
298
+ return self._client.request("PUT", "/assets/ai/config", headers={"If-Match": f'"{revision}"'}, json={
299
+ "enabled_actions": enabled_actions,
300
+ "monthly_spend_cap_micros": monthly_spend_cap_micros,
301
+ })
302
+
303
+ def job(self, job_id: str) -> dict[str, Any]:
304
+ return self._client.request("GET", f"/assets/jobs/{quote(job_id)}")
305
+
306
+ def cancel_job(self, job_id: str) -> dict[str, Any]:
307
+ return self._client.request("POST", f"/assets/jobs/{quote(job_id)}/cancel")
308
+
309
+ def create_lifecycle_rule(self, **payload: Any) -> dict[str, Any]:
310
+ return self._client.request("POST", "/assets/lifecycle-rules", json=payload)
311
+
312
+ def list_lifecycle_rules(self) -> dict[str, Any]:
313
+ return self._client.request("GET", "/assets/lifecycle-rules")
314
+
315
+ def delete_lifecycle_rule(self, rule_id: str) -> dict[str, Any]:
316
+ return self._client.request("DELETE", f"/assets/lifecycle-rules/{quote(rule_id)}")
317
+
318
+ def create_domain(self, domain: str) -> dict[str, Any]:
319
+ return self._client.request("POST", "/assets/domains", json={"domain": domain})
320
+
321
+ def list_domains(self) -> dict[str, Any]:
322
+ return self._client.request("GET", "/assets/domains")
323
+
324
+ def verify_domain(self, domain_id: str) -> dict[str, Any]:
325
+ return self._client.request("POST", f"/assets/domains/{quote(domain_id)}/verify")
326
+
327
+ def delete_domain(self, domain_id: str) -> dict[str, Any]:
328
+ return self._client.request("DELETE", f"/assets/domains/{quote(domain_id)}")
329
+
330
+ def analytics(self) -> dict[str, Any]:
331
+ return self._client.request("GET", "/assets/analytics")
332
+
333
+ def usage_summary(self) -> dict[str, Any]:
334
+ return self._client.request("GET", "/assets/summary")
335
+
336
+ def events(self, limit: int = 50) -> dict[str, Any]:
337
+ return self._client.request("GET", "/assets/events", params={"limit": limit})
338
+
339
+ def create_webhook(self, url: str, event_types: list[str] | None = None) -> dict[str, Any]:
340
+ return self._client.request("POST", "/assets/webhooks", json={"url": url, "event_types": event_types or ["asset.*"]})
341
+
342
+ def list_webhooks(self) -> dict[str, Any]:
343
+ return self._client.request("GET", "/assets/webhooks")
344
+
345
+ def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
346
+ return self._client.request("DELETE", f"/assets/webhooks/{quote(webhook_id)}")
347
+
348
+ def list_webhook_deliveries(self, webhook_id: str, limit: int = 50) -> dict[str, Any]:
349
+ return self._client.request("GET", f"/assets/webhooks/{quote(webhook_id)}/deliveries", params={"limit": limit})
350
+
351
+ def retry_webhook_delivery(self, delivery_id: str) -> dict[str, Any]:
352
+ return self._client.request("POST", f"/assets/webhook-deliveries/{quote(delivery_id)}/retry")
353
+
354
+ def purge(self, asset_id: str) -> dict[str, Any]:
355
+ return self._client.request("POST", f"/assets/{quote(asset_id)}/purge")
356
+
357
+ def bulk(self, *, action: str, items: list[dict[str, Any]], idempotency_key: str | None = None, **payload: Any) -> dict[str, Any]:
358
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
359
+ return self._client.request("POST", "/assets/bulk", json={"action": action, "items": items, **payload}, headers=headers)
360
+
361
+ def _multipart(self, method: str, suffix: str, file: str | Path | BinaryIO, fields: dict[str, Any], headers: dict[str, str] | None = None) -> dict[str, Any]:
75
362
  should_close = False
76
363
  if isinstance(file, (str, Path)):
77
364
  handle = Path(file).open("rb")
@@ -83,7 +370,21 @@ class AssetsClient:
83
370
 
84
371
  try:
85
372
  data = {key: value for key, value in fields.items() if value not in (None, "")}
86
- return self._client.request(method, suffix, data=data, files={"file": (filename, handle)})
373
+ return self._client.request(method, suffix, data=data, files={"file": (filename, handle)}, headers=headers)
87
374
  finally:
88
375
  if should_close:
89
376
  handle.close()
377
+
378
+
379
+ def _transform_spec(options: dict[str, Any]) -> str:
380
+ parts: list[str] = []
381
+ if options.get("width"):
382
+ parts.append(f"w_{options['width']}")
383
+ if options.get("height"):
384
+ parts.append(f"h_{options['height']}")
385
+ parts.append(f"c_{options.get('crop') or 'fit'}")
386
+ parts.append(f"f_{options.get('format') or 'auto'}")
387
+ parts.append(f"q_{options.get('quality') or 'auto'}")
388
+ parts.append(f"g_{options.get('gravity') or 'centre'}")
389
+ parts.append(f"a_{options.get('animation') or 'first'}")
390
+ return ",".join(parts)
stackshift/client.py CHANGED
@@ -12,6 +12,8 @@ class StackShift:
12
12
  *,
13
13
  api_key: str | None = None,
14
14
  base_url: str = "https://api.stackshift.cloud/api/v1",
15
+ mail_base_url: str = "https://api.stackshift.cloud/v1",
16
+ cdn_base_url: str = "https://cdn.stackshift.cloud",
15
17
  session: requests.Session | None = None,
16
18
  ) -> None:
17
19
  api_key = api_key or os.getenv("STACKSHIFT_API_KEY")
@@ -20,25 +22,58 @@ class StackShift:
20
22
 
21
23
  self.api_key = api_key
22
24
  self.base_url = base_url.rstrip("/")
25
+ self.mail_base_url = mail_base_url.rstrip("/")
26
+ self.cdn_base_url = cdn_base_url.rstrip("/")
23
27
  self.session = session or requests.Session()
24
28
 
25
29
  from .assets import AssetsClient
30
+ from .mail import MailClient
26
31
  from .projects import ProjectsClient
27
32
 
28
33
  self.projects = ProjectsClient(self)
29
34
  self.assets = AssetsClient(self)
35
+ self.mail = MailClient(self)
30
36
 
31
37
  def request(self, method: str, path: str, **kwargs: Any) -> Any:
38
+ return self._request(self.base_url, method, path, **kwargs)
39
+
40
+ def mail_request(self, method: str, path: str, **kwargs: Any) -> Any:
41
+ return self._request(self.mail_base_url, method, path, **kwargs)
42
+
43
+ def _request(self, base_url: str, method: str, path: str, **kwargs: Any) -> Any:
32
44
  headers = kwargs.pop("headers", {})
33
45
  headers["Authorization"] = f"Bearer {self.api_key}"
34
46
  headers["User-Agent"] = "StackShift-Python/0.1"
35
47
 
36
- response = self.session.request(method, f"{self.base_url}{path}", headers=headers, **kwargs)
48
+ response = self.session.request(method, f"{base_url}{path}", headers=headers, **kwargs)
37
49
  payload = response.json() if response.content else None
38
50
  if response.status_code < 200 or response.status_code >= 300:
51
+ error = payload.get("error") if isinstance(payload, dict) else None
52
+ if isinstance(error, dict):
53
+ raise StackShiftAPIError(
54
+ error.get("message") or f"StackShift API request failed with {response.status_code}",
55
+ status_code=response.status_code,
56
+ code=error.get("code"),
57
+ details=error.get("details"),
58
+ )
39
59
  message = payload.get("message") if isinstance(payload, dict) else None
40
- raise RuntimeError(message or f"StackShift API request failed with {response.status_code}")
60
+ raise StackShiftAPIError(message or f"StackShift API request failed with {response.status_code}", status_code=response.status_code)
41
61
 
42
62
  if isinstance(payload, dict) and "success" in payload and "data" in payload:
43
63
  return payload["data"]
44
64
  return payload
65
+
66
+
67
+ class StackShiftAPIError(RuntimeError):
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ status_code: int,
73
+ code: str | None = None,
74
+ details: dict[str, Any] | None = None,
75
+ ) -> None:
76
+ super().__init__(message)
77
+ self.status_code = status_code
78
+ self.code = code
79
+ self.details = details
stackshift/mail.py ADDED
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from .mail_advanced import MailAnalyticsClient, MailBatchClient, MailInboundClient, MailScheduledClient
7
+ from .mail_phase7 import MailEventsClient, MailWebhooksClient
8
+ from .mail_reputation import MailLimitsClient, MailReputationClient
9
+
10
+
11
+ class MailClient:
12
+ def __init__(self, client: Any) -> None:
13
+ self._client = client
14
+ self.domains = MailDomainsClient(client)
15
+ self.messages = MailMessagesClient(client)
16
+ self.suppressions = MailSuppressionsClient(client)
17
+ self.bounces = MailBouncesClient(client)
18
+ self.otp = MailOTPClient(client)
19
+ self.templates = MailTemplatesClient(client)
20
+ self.events = MailEventsClient(client)
21
+ self.webhooks = MailWebhooksClient(client)
22
+ self.limits = MailLimitsClient(client)
23
+ self.reputation = MailReputationClient(client)
24
+ self.scheduled = MailScheduledClient(client)
25
+ self.batch = MailBatchClient(client)
26
+ self.inbound = MailInboundClient(client)
27
+ self.analytics = MailAnalyticsClient(client)
28
+
29
+ def send(
30
+ self,
31
+ *,
32
+ from_: str | dict[str, str],
33
+ to: str | list[str] | list[dict[str, str]],
34
+ subject: str,
35
+ html: str | None = None,
36
+ text: str | None = None,
37
+ cc: str | list[str] | list[dict[str, str]] | None = None,
38
+ bcc: str | list[str] | list[dict[str, str]] | None = None,
39
+ reply_to: str | dict[str, str] | None = None,
40
+ idempotency_key: str | None = None,
41
+ attachments: list[dict[str, Any]] | None = None,
42
+ ) -> dict[str, Any]:
43
+ payload = {
44
+ "from": from_,
45
+ "to": to,
46
+ "cc": cc,
47
+ "bcc": bcc,
48
+ "replyTo": reply_to,
49
+ "subject": subject,
50
+ "html": html,
51
+ "text": text,
52
+ "idempotencyKey": idempotency_key,
53
+ "attachments": attachments,
54
+ }
55
+ clean = {key: value for key, value in payload.items() if value is not None}
56
+ return self._client.mail_request("POST", "/mail/send", json=clean)
57
+
58
+ def send_template(
59
+ self,
60
+ *,
61
+ template: str,
62
+ to: str | list[str] | list[dict[str, str]],
63
+ data: dict[str, Any],
64
+ from_: str | dict[str, str] | None = None,
65
+ version_id: str | None = None,
66
+ cc: str | list[str] | list[dict[str, str]] | None = None,
67
+ bcc: str | list[str] | list[dict[str, str]] | None = None,
68
+ reply_to: str | dict[str, str] | None = None,
69
+ idempotency_key: str | None = None,
70
+ attachments: list[dict[str, Any]] | None = None,
71
+ ) -> dict[str, Any]:
72
+ payload = {
73
+ "template": template,
74
+ "versionId": version_id,
75
+ "to": to,
76
+ "from": from_,
77
+ "cc": cc,
78
+ "bcc": bcc,
79
+ "replyTo": reply_to,
80
+ "data": data,
81
+ "idempotencyKey": idempotency_key,
82
+ "attachments": attachments,
83
+ }
84
+ clean = {key: value for key, value in payload.items() if value is not None}
85
+ return self._client.mail_request("POST", "/mail/send-template", json=clean)
86
+
87
+
88
+ class MailTemplatesClient:
89
+ def __init__(self, client: Any) -> None:
90
+ self._client = client
91
+
92
+ def create(
93
+ self,
94
+ *,
95
+ name: str,
96
+ slug: str,
97
+ subject: str,
98
+ description: str | None = None,
99
+ html: str | None = None,
100
+ text: str | None = None,
101
+ ) -> dict[str, Any]:
102
+ payload = {
103
+ "name": name,
104
+ "slug": slug,
105
+ "description": description,
106
+ "subject": subject,
107
+ "html": html,
108
+ "text": text,
109
+ }
110
+ clean = {key: value for key, value in payload.items() if value is not None}
111
+ return self._client.mail_request("POST", "/mail/templates", json=clean)
112
+
113
+ def list(
114
+ self,
115
+ *,
116
+ status: str | None = None,
117
+ limit: int | None = None,
118
+ cursor: str | None = None,
119
+ search: str | None = None,
120
+ ) -> dict[str, Any]:
121
+ params = {"status": status, "limit": limit, "cursor": cursor, "search": search}
122
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
123
+ return self._client.mail_request("GET", "/mail/templates", params=clean)
124
+
125
+ def get(self, template_id_or_slug: str) -> dict[str, Any]:
126
+ return self._client.mail_request("GET", f"/mail/templates/{quote(template_id_or_slug)}")
127
+
128
+ def update(
129
+ self,
130
+ template_id: str,
131
+ *,
132
+ name: str | None = None,
133
+ description: str | None = None,
134
+ subject: str | None = None,
135
+ html: str | None = None,
136
+ text: str | None = None,
137
+ ) -> dict[str, Any]:
138
+ payload = {
139
+ "name": name,
140
+ "description": description,
141
+ "subject": subject,
142
+ "html": html,
143
+ "text": text,
144
+ }
145
+ clean = {key: value for key, value in payload.items() if value is not None}
146
+ return self._client.mail_request("PATCH", f"/mail/templates/{quote(template_id)}", json=clean)
147
+
148
+ def delete(self, template_id: str) -> None:
149
+ self._client.mail_request("DELETE", f"/mail/templates/{quote(template_id)}")
150
+
151
+ def versions(self, template_id: str) -> dict[str, Any]:
152
+ return self._client.mail_request("GET", f"/mail/templates/{quote(template_id)}/versions")
153
+
154
+ def get_version(self, template_id: str, version_id: str) -> dict[str, Any]:
155
+ return self._client.mail_request("GET", f"/mail/templates/{quote(template_id)}/versions/{quote(version_id)}")
156
+
157
+ def activate_version(self, template_id: str, version_id: str) -> dict[str, Any]:
158
+ return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/versions/{quote(version_id)}/activate")
159
+
160
+ def preview(self, template_id: str, *, data: dict[str, Any], version_id: str | None = None) -> dict[str, Any]:
161
+ payload = {"data": data, "versionId": version_id}
162
+ clean = {key: value for key, value in payload.items() if value is not None}
163
+ return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/preview", json=clean)
164
+
165
+ def test_send(
166
+ self,
167
+ template_id: str,
168
+ *,
169
+ to: str | list[str] | list[dict[str, str]],
170
+ data: dict[str, Any],
171
+ from_: str | dict[str, str] | None = None,
172
+ version_id: str | None = None,
173
+ ) -> dict[str, Any]:
174
+ payload = {"to": to, "from": from_, "data": data, "versionId": version_id}
175
+ clean = {key: value for key, value in payload.items() if value is not None}
176
+ return self._client.mail_request("POST", f"/mail/templates/{quote(template_id)}/test-send", json=clean)
177
+
178
+
179
+ class MailDomainsClient:
180
+ def __init__(self, client: Any) -> None:
181
+ self._client = client
182
+
183
+ def create(self, domain: str) -> dict[str, Any]:
184
+ return self._client.mail_request("POST", "/mail/domains", json={"domain": domain})
185
+
186
+ def list(self) -> dict[str, Any]:
187
+ return self._client.mail_request("GET", "/mail/domains")
188
+
189
+ def get(self, domain_id: str) -> dict[str, Any]:
190
+ return self._client.mail_request("GET", f"/mail/domains/{quote(domain_id)}")
191
+
192
+ def verify(self, domain_id: str) -> dict[str, Any]:
193
+ return self._client.mail_request("POST", f"/mail/domains/{quote(domain_id)}/verify")
194
+
195
+ def delete(self, domain_id: str) -> None:
196
+ self._client.mail_request("DELETE", f"/mail/domains/{quote(domain_id)}")
197
+
198
+
199
+ class MailMessagesClient:
200
+ def __init__(self, client: Any) -> None:
201
+ self._client = client
202
+
203
+ def list(
204
+ self,
205
+ *,
206
+ status: str | None = None,
207
+ limit: int | None = None,
208
+ cursor: str | None = None,
209
+ to: str | None = None,
210
+ from_: str | None = None,
211
+ ) -> dict[str, Any]:
212
+ params = {
213
+ "status": status,
214
+ "limit": limit,
215
+ "cursor": cursor,
216
+ "to": to,
217
+ "from": from_,
218
+ }
219
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
220
+ return self._client.mail_request("GET", "/mail/messages", params=clean)
221
+
222
+ def get(self, message_id: str) -> dict[str, Any]:
223
+ return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}")
224
+
225
+ def attempts(self, message_id: str) -> dict[str, Any]:
226
+ return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/attempts")
227
+
228
+ def logs(self, message_id: str) -> dict[str, Any]:
229
+ return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/logs")
230
+
231
+ def bounces(self, message_id: str) -> dict[str, Any]:
232
+ return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/bounces")
233
+
234
+ def timeline(self, message_id: str) -> dict[str, Any]:
235
+ return self._client.mail_request("GET", f"/mail/messages/{quote(message_id)}/timeline")
236
+
237
+
238
+ class MailSuppressionsClient:
239
+ def __init__(self, client: Any) -> None:
240
+ self._client = client
241
+
242
+ def list(
243
+ self,
244
+ *,
245
+ email: str | None = None,
246
+ reason: str | None = None,
247
+ limit: int | None = None,
248
+ cursor: str | None = None,
249
+ ) -> dict[str, Any]:
250
+ params = {
251
+ "email": email,
252
+ "reason": reason,
253
+ "limit": limit,
254
+ "cursor": cursor,
255
+ }
256
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
257
+ return self._client.mail_request("GET", "/mail/suppressions", params=clean)
258
+
259
+ def create(
260
+ self,
261
+ *,
262
+ email: str,
263
+ reason: str | None = None,
264
+ metadata: dict[str, Any] | None = None,
265
+ ) -> dict[str, Any]:
266
+ payload = {
267
+ "email": email,
268
+ "reason": reason,
269
+ "metadata": metadata,
270
+ }
271
+ clean = {key: value for key, value in payload.items() if value is not None}
272
+ return self._client.mail_request("POST", "/mail/suppressions", json=clean)
273
+
274
+ def delete(self, suppression_id: str) -> None:
275
+ self._client.mail_request("DELETE", f"/mail/suppressions/{quote(suppression_id)}")
276
+
277
+
278
+ class MailBouncesClient:
279
+ def __init__(self, client: Any) -> None:
280
+ self._client = client
281
+
282
+ def list(
283
+ self,
284
+ *,
285
+ email: str | None = None,
286
+ type: str | None = None,
287
+ message_id: str | None = None,
288
+ limit: int | None = None,
289
+ cursor: str | None = None,
290
+ ) -> dict[str, Any]:
291
+ params = {
292
+ "email": email,
293
+ "type": type,
294
+ "messageId": message_id,
295
+ "limit": limit,
296
+ "cursor": cursor,
297
+ }
298
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
299
+ return self._client.mail_request("GET", "/mail/bounces", params=clean)
300
+
301
+
302
+ class MailOTPClient:
303
+ def __init__(self, client: Any) -> None:
304
+ self._client = client
305
+ self.challenges = MailOTPChallengesClient(client)
306
+
307
+ def send(
308
+ self,
309
+ *,
310
+ to: str,
311
+ purpose: str,
312
+ from_: str | None = None,
313
+ expires_in: str | None = None,
314
+ code_length: int | None = None,
315
+ metadata: dict[str, Any] | None = None,
316
+ idempotency_key: str | None = None,
317
+ subject: str | None = None,
318
+ brand_name: str | None = None,
319
+ ) -> dict[str, Any]:
320
+ payload = {
321
+ "to": to,
322
+ "purpose": purpose,
323
+ "from": from_,
324
+ "expiresIn": expires_in,
325
+ "codeLength": code_length,
326
+ "metadata": metadata,
327
+ "idempotencyKey": idempotency_key,
328
+ "subject": subject,
329
+ "brandName": brand_name,
330
+ }
331
+ clean = {key: value for key, value in payload.items() if value is not None}
332
+ return self._client.mail_request("POST", "/mail/otp/send", json=clean)
333
+
334
+ def verify(
335
+ self,
336
+ *,
337
+ code: str,
338
+ to: str | None = None,
339
+ purpose: str | None = None,
340
+ challenge_id: str | None = None,
341
+ ) -> dict[str, Any]:
342
+ payload = {
343
+ "to": to,
344
+ "purpose": purpose,
345
+ "challengeId": challenge_id,
346
+ "code": code,
347
+ }
348
+ clean = {key: value for key, value in payload.items() if value is not None}
349
+ return self._client.mail_request("POST", "/mail/otp/verify", json=clean)
350
+
351
+
352
+ class MailOTPChallengesClient:
353
+ def __init__(self, client: Any) -> None:
354
+ self._client = client
355
+
356
+ def list(
357
+ self,
358
+ *,
359
+ to: str | None = None,
360
+ purpose: str | None = None,
361
+ status: str | None = None,
362
+ limit: int | None = None,
363
+ cursor: str | None = None,
364
+ ) -> dict[str, Any]:
365
+ params = {
366
+ "to": to,
367
+ "purpose": purpose,
368
+ "status": status,
369
+ "limit": limit,
370
+ "cursor": cursor,
371
+ }
372
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
373
+ return self._client.mail_request("GET", "/mail/otp/challenges", params=clean)
374
+
375
+ def get(self, challenge_id: str) -> dict[str, Any]:
376
+ return self._client.mail_request("GET", f"/mail/otp/challenges/{quote(challenge_id)}")
377
+
378
+ def cancel(self, challenge_id: str) -> dict[str, Any]:
379
+ return self._client.mail_request("POST", f"/mail/otp/challenges/{quote(challenge_id)}/cancel")
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+
7
+ class MailScheduledClient:
8
+ def __init__(self, client: Any) -> None:
9
+ self._client = client
10
+
11
+ def create(self, **payload: Any) -> dict[str, Any]:
12
+ clean = {key: value for key, value in payload.items() if value is not None}
13
+ return self._client.mail_request("POST", "/mail/scheduled", json=clean)
14
+
15
+ def list(self) -> dict[str, Any]:
16
+ return self._client.mail_request("GET", "/mail/scheduled")
17
+
18
+ def get(self, scheduled_id: str) -> dict[str, Any]:
19
+ return self._client.mail_request("GET", f"/mail/scheduled/{quote(scheduled_id)}")
20
+
21
+ def cancel(self, scheduled_id: str) -> dict[str, Any]:
22
+ return self._client.mail_request("POST", f"/mail/scheduled/{quote(scheduled_id)}/cancel")
23
+
24
+
25
+ class MailBatchClient:
26
+ def __init__(self, client: Any) -> None:
27
+ self._client = client
28
+
29
+ def send(self, *, from_: str | dict[str, str], messages: list[dict[str, Any]]) -> dict[str, Any]:
30
+ return self._client.mail_request("POST", "/mail/batch", json={"from": from_, "messages": messages})
31
+
32
+ def send_template(
33
+ self,
34
+ *,
35
+ template: str,
36
+ from_: str | dict[str, str],
37
+ recipients: list[dict[str, Any]],
38
+ version_id: str | None = None,
39
+ ) -> dict[str, Any]:
40
+ payload = {"template": template, "from": from_, "recipients": recipients, "versionId": version_id}
41
+ clean = {key: value for key, value in payload.items() if value is not None}
42
+ return self._client.mail_request("POST", "/mail/batch-template", json=clean)
43
+
44
+
45
+ class MailInboundClient:
46
+ def __init__(self, client: Any) -> None:
47
+ self.domains = MailInboundDomainsClient(client)
48
+ self.messages = MailInboundMessagesClient(client)
49
+
50
+
51
+ class MailInboundDomainsClient:
52
+ def __init__(self, client: Any) -> None:
53
+ self._client = client
54
+
55
+ def create(self, domain: str) -> dict[str, Any]:
56
+ return self._client.mail_request("POST", "/mail/inbound/domains", json={"domain": domain})
57
+
58
+ def list(self) -> dict[str, Any]:
59
+ return self._client.mail_request("GET", "/mail/inbound/domains")
60
+
61
+ def verify(self, domain_id: str) -> dict[str, Any]:
62
+ return self._client.mail_request("POST", f"/mail/inbound/domains/{quote(domain_id)}/verify")
63
+
64
+
65
+ class MailInboundMessagesClient:
66
+ def __init__(self, client: Any) -> None:
67
+ self._client = client
68
+
69
+ def list(self) -> dict[str, Any]:
70
+ return self._client.mail_request("GET", "/mail/inbound/messages")
71
+
72
+ def get(self, message_id: str) -> dict[str, Any]:
73
+ return self._client.mail_request("GET", f"/mail/inbound/messages/{quote(message_id)}")
74
+
75
+
76
+ class MailAnalyticsClient:
77
+ def __init__(self, client: Any) -> None:
78
+ self._client = client
79
+
80
+ def get(
81
+ self,
82
+ *,
83
+ range_: str | None = None,
84
+ from_: str | None = None,
85
+ to: str | None = None,
86
+ interval: str | None = None,
87
+ domain: str | None = None,
88
+ template: str | None = None,
89
+ event: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ params = {
92
+ "range": range_,
93
+ "from": from_,
94
+ "to": to,
95
+ "interval": interval,
96
+ "domain": domain,
97
+ "template": template,
98
+ "event": event,
99
+ }
100
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
101
+ return self._client.mail_request("GET", "/mail/analytics", params=clean)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+
10
+ class MailEventsClient:
11
+ def __init__(self, client: Any) -> None:
12
+ self._client = client
13
+
14
+ def list(
15
+ self,
16
+ *,
17
+ type: str | None = None,
18
+ resource_type: str | None = None,
19
+ resource_id: str | None = None,
20
+ message_id: str | None = None,
21
+ limit: int | None = None,
22
+ cursor: str | None = None,
23
+ from_: str | None = None,
24
+ to: str | None = None,
25
+ ) -> dict[str, Any]:
26
+ params = {
27
+ "type": type,
28
+ "resourceType": resource_type,
29
+ "resourceId": resource_id,
30
+ "messageId": message_id,
31
+ "limit": limit,
32
+ "cursor": cursor,
33
+ "from": from_,
34
+ "to": to,
35
+ }
36
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
37
+ return self._client.mail_request("GET", "/mail/events", params=clean)
38
+
39
+ def get(self, event_id: str) -> dict[str, Any]:
40
+ return self._client.mail_request("GET", f"/mail/events/{quote(event_id)}")
41
+
42
+
43
+ class MailWebhooksClient:
44
+ def __init__(self, client: Any) -> None:
45
+ self._client = client
46
+
47
+ def create(
48
+ self,
49
+ *,
50
+ url: str,
51
+ event_types: list[str],
52
+ description: str | None = None,
53
+ ) -> dict[str, Any]:
54
+ payload = {"url": url, "description": description, "eventTypes": event_types}
55
+ clean = {key: value for key, value in payload.items() if value is not None}
56
+ return self._client.mail_request("POST", "/mail/webhooks", json=clean)
57
+
58
+ def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
59
+ params = {"limit": limit, "cursor": cursor}
60
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
61
+ return self._client.mail_request("GET", "/mail/webhooks", params=clean)
62
+
63
+ def get(self, webhook_id: str) -> dict[str, Any]:
64
+ return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}")
65
+
66
+ def update(
67
+ self,
68
+ webhook_id: str,
69
+ *,
70
+ url: str | None = None,
71
+ description: str | None = None,
72
+ event_types: list[str] | None = None,
73
+ status: str | None = None,
74
+ ) -> dict[str, Any]:
75
+ payload = {"url": url, "description": description, "eventTypes": event_types, "status": status}
76
+ clean = {key: value for key, value in payload.items() if value is not None}
77
+ return self._client.mail_request("PATCH", f"/mail/webhooks/{quote(webhook_id)}", json=clean)
78
+
79
+ def rotate_secret(self, webhook_id: str) -> dict[str, Any]:
80
+ return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/rotate-secret")
81
+
82
+ def delete(self, webhook_id: str) -> None:
83
+ self._client.mail_request("DELETE", f"/mail/webhooks/{quote(webhook_id)}")
84
+
85
+ def deliveries(
86
+ self,
87
+ webhook_id: str,
88
+ *,
89
+ status: str | None = None,
90
+ limit: int | None = None,
91
+ cursor: str | None = None,
92
+ ) -> dict[str, Any]:
93
+ params = {"status": status, "limit": limit, "cursor": cursor}
94
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
95
+ return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}/deliveries", params=clean)
96
+
97
+ def get_delivery(self, delivery_id: str) -> dict[str, Any]:
98
+ return self._client.mail_request("GET", f"/mail/webhook-deliveries/{quote(delivery_id)}")
99
+
100
+ def retry_delivery(self, delivery_id: str) -> dict[str, Any]:
101
+ return self._client.mail_request("POST", f"/mail/webhook-deliveries/{quote(delivery_id)}/retry")
102
+
103
+ def verify_signature(
104
+ self,
105
+ *,
106
+ raw_body: bytes | str,
107
+ signature_header: str | None,
108
+ timestamp_header: str | None,
109
+ secret: str,
110
+ tolerance_seconds: int = 300,
111
+ now: int | None = None,
112
+ ) -> bool:
113
+ return verify_webhook_signature(
114
+ raw_body=raw_body,
115
+ signature_header=signature_header,
116
+ timestamp_header=timestamp_header,
117
+ secret=secret,
118
+ tolerance_seconds=tolerance_seconds,
119
+ now=now,
120
+ )
121
+
122
+
123
+ def verify_webhook_signature(
124
+ *,
125
+ raw_body: bytes | str,
126
+ signature_header: str | None,
127
+ timestamp_header: str | None,
128
+ secret: str,
129
+ tolerance_seconds: int = 300,
130
+ now: int | None = None,
131
+ ) -> bool:
132
+ if not raw_body or not signature_header or not timestamp_header or not secret:
133
+ return False
134
+ try:
135
+ timestamp = int(timestamp_header)
136
+ except ValueError:
137
+ return False
138
+ current = int(time.time()) if now is None else int(now)
139
+ if abs(current - timestamp) > tolerance_seconds:
140
+ return False
141
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
142
+ signed = timestamp_header.encode() + b"." + body
143
+ expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
144
+ provided = _signature_v1(signature_header)
145
+ return bool(provided) and hmac.compare_digest(expected, provided)
146
+
147
+
148
+ def _signature_v1(header: str) -> str:
149
+ for part in header.split(","):
150
+ key, _, value = part.strip().partition("=")
151
+ if key == "v1":
152
+ return value
153
+ return ""
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MailLimitsClient:
7
+ def __init__(self, client: Any) -> None:
8
+ self._client = client
9
+
10
+ def get(self) -> dict[str, Any]:
11
+ return self._client.mail_request("GET", "/mail/limits")
12
+
13
+
14
+ class MailReputationClient:
15
+ def __init__(self, client: Any) -> None:
16
+ self._client = client
17
+ self.events = MailReputationEventsClient(client)
18
+
19
+ def get(self) -> dict[str, Any]:
20
+ return self._client.mail_request("GET", "/mail/reputation")
21
+
22
+
23
+ class MailReputationEventsClient:
24
+ def __init__(self, client: Any) -> None:
25
+ self._client = client
26
+
27
+ def list(
28
+ self,
29
+ *,
30
+ severity: str | None = None,
31
+ type: str | None = None,
32
+ limit: int | None = None,
33
+ cursor: str | None = None,
34
+ ) -> dict[str, Any]:
35
+ params = {"severity": severity, "type": type, "limit": limit, "cursor": cursor}
36
+ clean = {key: value for key, value in params.items() if value not in (None, "")}
37
+ return self._client.mail_request("GET", "/mail/reputation/events", params=clean)
38
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackshift
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Official Python SDK for StackShift.
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: requests>=2.31
@@ -0,0 +1,12 @@
1
+ stackshift/__init__.py,sha256=e5QZUzE6m_195Cvvawyv8p_M18eNkQzW6sBgBn3SlRo,1008
2
+ stackshift/assets.py,sha256=qRzL9BNZSneNBc3es_vLsY4_hh_r3zOfdO_rAKQq5LU,22949
3
+ stackshift/client.py,sha256=AcJivv4VRCQ756MRrxCrLi0Q4in-4np0Re8ceQSTF1Y,3000
4
+ stackshift/mail.py,sha256=vegCniMcWBvHtz_UGAfNfVxFYTety8H3KKFQsWfKZSs,13462
5
+ stackshift/mail_advanced.py,sha256=-lzajXqlrkR8qA4YpRX5Sv3VwcEQITtKfIXbWwjRRQE,3533
6
+ stackshift/mail_phase7.py,sha256=Ud2fyKwt-vpaw7vgEayLw5q9tiDIF-fkfTBaoGJMJBI,5377
7
+ stackshift/mail_reputation.py,sha256=mPvPkZeqcQc5808PhBK7gYsgf4Fk_U19nRxSsJ60MGU,1112
8
+ stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
9
+ stackshift-0.1.2.dist-info/METADATA,sha256=GKbdBmBmZWOUs8DTe-amYxZNbplrh7pAlkaRtkBWQ14,152
10
+ stackshift-0.1.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ stackshift-0.1.2.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
12
+ stackshift-0.1.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,8 +0,0 @@
1
- stackshift/__init__.py,sha256=Z0Q6SrKZyIx-YBt5pdIVXgnJGF4TeBYKtUA7vdxKlU4,161
2
- stackshift/assets.py,sha256=qvu1qXd5wJqeWc4hpdGYcorXXMtZUNEwjlaT61lu7-A,3119
3
- stackshift/client.py,sha256=YM9xgy0145f37l8x6A-Ecrg9zw5Z1E6DTRKEdiQ0P4g,1566
4
- stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
5
- stackshift-0.1.0.dist-info/METADATA,sha256=M9M2rhNT9c7bNRMCQkRTQ-Db8mYZ7T_Ccxva3KXCnq8,152
6
- stackshift-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
- stackshift-0.1.0.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
8
- stackshift-0.1.0.dist-info/RECORD,,