stackshift 1.2.1__py3-none-any.whl → 1.3.1__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
@@ -13,6 +13,15 @@ from .mail_events_webhooks import MailEventsClient, MailWebhooksClient, verify_w
13
13
  from .mail_streams import MailStreamsClient
14
14
  from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
15
15
  from .mail_exports import MailExportsClient
16
+ from .mail_mailboxes import (
17
+ MailboxAliasesClient,
18
+ MailboxDomainsClient,
19
+ MailboxGroupsClient,
20
+ MailboxImportsClient,
21
+ MailboxesClient,
22
+ MailboxRoutesClient,
23
+ )
24
+ from .partner import PartnerClient
16
25
  from .projects import ProjectsClient
17
26
  from .workload_security import WorkloadSecurityClient
18
27
 
@@ -35,6 +44,13 @@ __all__ = [
35
44
  "MailAudiencesClient",
36
45
  "MailCampaignsClient",
37
46
  "MailExportsClient",
47
+ "MailboxAliasesClient",
48
+ "MailboxDomainsClient",
49
+ "MailboxGroupsClient",
50
+ "MailboxImportsClient",
51
+ "MailboxesClient",
52
+ "MailboxRoutesClient",
53
+ "PartnerClient",
38
54
  "ProjectsClient",
39
55
  "WorkloadSecurityClient",
40
56
  "asset_transform_options",
@@ -42,3 +58,13 @@ __all__ = [
42
58
  "get_asset_transform_preset",
43
59
  "verify_webhook_signature",
44
60
  ]
61
+
62
+ from .support_actions import (ActionProtocolError, sign_action, verify_action, verify_receipt, encode_receipt)
63
+ from .support_action_receiver import ActionClaim, ActionDurableStore, SignedActionResponse, receive_action
64
+ from .support_action_sqlite import SQLiteActionStore
65
+ from .support_action_types import ActionDescriptor, ActionField, ActionManifest, ActionRequest, ActionReceipt
66
+ __all__ += ["ActionProtocolError", "sign_action", "verify_action", "verify_receipt", "encode_receipt", "ActionClaim", "ActionDurableStore", "SignedActionResponse", "receive_action", "SQLiteActionStore", "ActionDescriptor", "ActionField", "ActionManifest", "ActionRequest", "ActionReceipt"]
67
+ from .support_action_manifest import validate_action_catalog
68
+ __all__ += ['validate_action_catalog']
69
+ from .support_action_types import ActionInstallationInput, ActionInstallationCredentials
70
+ __all__ += ['ActionInstallationInput', 'ActionInstallationCredentials']
stackshift/assets.py CHANGED
@@ -230,6 +230,23 @@ class AssetsClient(AssetWorkflowsMixin):
230
230
  headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
231
231
  return self._client.request("POST", path, headers=headers)
232
232
 
233
+ def usage(self, asset_id: str) -> dict[str, Any]:
234
+ return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/usage")
235
+
236
+ def hold(self, asset_id: str) -> dict[str, Any]:
237
+ return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/hold")
238
+
239
+ def create_reupload_request(
240
+ self, asset_id: str, *, email: str | None = None, message: str | None = None
241
+ ) -> dict[str, Any]:
242
+ payload = {key: value for key, value in {"email": email, "message": message}.items() if value}
243
+ return self._client.request(
244
+ "POST", f"/assets/{quote(asset_id, safe='')}/reupload-requests", json=payload
245
+ )
246
+
247
+ def reupload_requests(self, asset_id: str) -> dict[str, Any]:
248
+ return self._client.request("GET", f"/assets/{quote(asset_id, safe='')}/reupload-requests")
249
+
233
250
  def versions(self, asset_id: str) -> dict[str, Any]:
234
251
  return self._client.request("GET", f"/assets/{asset_id}/versions")
235
252
 
@@ -17,6 +17,14 @@ class AssetWorkflowsMixin:
17
17
  json={"name": name, "definition": definition, "eager": eager},
18
18
  )
19
19
 
20
+ def estimate_transform(
21
+ self, asset_id: str, version_id: str, definition: dict[str, Any]
22
+ ) -> dict[str, Any]:
23
+ return self._client.request(
24
+ "POST", f"/assets/{quote(asset_id, safe='')}/transforms/estimate",
25
+ json={"version_id": version_id, "definition": definition},
26
+ )
27
+
20
28
  def materialize_transform(
21
29
  self,
22
30
  asset_id: str,
stackshift/client.py CHANGED
@@ -12,7 +12,7 @@ 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",
15
+ mail_base_url: str = "https://api.stackshift.cloud/api/v1",
16
16
  cdn_base_url: str = "https://cdn.stackshift.cloud",
17
17
  session: requests.Session | None = None,
18
18
  ) -> None:
@@ -29,6 +29,7 @@ class StackShift:
29
29
  from .ai_credits import AICreditsClient
30
30
  from .assets import AssetsClient
31
31
  from .mail import MailClient
32
+ from .partner import PartnerClient
32
33
  from .projects import ProjectsClient
33
34
  from .workload_security import WorkloadSecurityClient
34
35
 
@@ -36,6 +37,7 @@ class StackShift:
36
37
  self.projects = ProjectsClient(self)
37
38
  self.assets = AssetsClient(self)
38
39
  self.mail = MailClient(self)
40
+ self.partner = PartnerClient(self)
39
41
  self.workload_security = WorkloadSecurityClient(self)
40
42
 
41
43
  def request(self, method: str, path: str, **kwargs: Any) -> Any:
stackshift/mail.py CHANGED
@@ -13,6 +13,14 @@ from .mail_marketing import MailAutomationsClient, MailFormsClient, MailSegments
13
13
  from .mail_migration import MailMigrationClient
14
14
  from .mail_campaigns import MailAudiencesClient, MailCampaignsClient
15
15
  from .mail_exports import MailExportsClient
16
+ from .mail_mailboxes import (
17
+ MailboxAliasesClient,
18
+ MailboxDomainsClient,
19
+ MailboxGroupsClient,
20
+ MailboxImportsClient,
21
+ MailboxesClient,
22
+ MailboxRoutesClient,
23
+ )
16
24
  from .mail_tracking import MailTrackingClient
17
25
 
18
26
 
@@ -38,6 +46,12 @@ class MailClient:
38
46
  self.campaigns = MailCampaignsClient(client)
39
47
  self.tracking = MailTrackingClient(client)
40
48
  self.exports = MailExportsClient(client)
49
+ self.mailbox_domains = MailboxDomainsClient(client)
50
+ self.mailboxes = MailboxesClient(client)
51
+ self.aliases = MailboxAliasesClient(client)
52
+ self.groups = MailboxGroupsClient(client)
53
+ self.routes = MailboxRoutesClient(client)
54
+ self.mailbox_imports = MailboxImportsClient(client)
41
55
  self.limits = MailLimitsClient(client)
42
56
  self.reputation = MailReputationClient(client)
43
57
  self.scheduled = MailScheduledClient(client)
@@ -297,12 +311,14 @@ class MailSuppressionsClient:
297
311
  *,
298
312
  email: str | None = None,
299
313
  reason: str | None = None,
314
+ brand_id: str | None = None,
300
315
  limit: int | None = None,
301
316
  cursor: str | None = None,
302
317
  ) -> dict[str, Any]:
303
318
  params = {
304
319
  "email": email,
305
320
  "reason": reason,
321
+ "brandId": brand_id,
306
322
  "limit": limit,
307
323
  "cursor": cursor,
308
324
  }
@@ -7,3 +7,6 @@ class MailBillingClient:
7
7
 
8
8
  def summary(self) -> dict[str, Any]:
9
9
  return self._client.mail_request("GET", "/mail/billing")
10
+
11
+ def period_usage(self, period: str) -> dict[str, Any]:
12
+ return self._client.mail_request("GET", "/mail/billing", params={"period": period})
stackshift/mail_brands.py CHANGED
@@ -28,7 +28,7 @@ class MailBrandsClient:
28
28
 
29
29
 
30
30
  def _brand_fields(fields: dict[str, Any]) -> dict[str, Any]:
31
- names = {"logo_url": "logoUrl", "sending_domain_id": "sendingDomainId", "default_from": "defaultFrom", "reply_to": "replyTo", "postal_address": "postalAddress", "website": "website", "support_email": "supportEmail", "editable_fields": "editableFields"}
31
+ names = {"logo_url": "logoUrl", "sending_domain_id": "sendingDomainId", "default_from": "defaultFrom", "reply_to": "replyTo", "postal_address": "postalAddress", "website": "website", "support_email": "supportEmail", "editable_fields": "editableFields", "merchant_ref": "merchantRef"}
32
32
  unknown = fields.keys() - names.keys() - set(names.values())
33
33
  if unknown:
34
34
  raise ValueError("Unknown brand fields: " + ", ".join(sorted(unknown)))
@@ -12,8 +12,9 @@ class MailAudiencesClient:
12
12
  payload = {"brandId": brand_id, "name": name, "description": description}
13
13
  return self._client.mail_request("POST", "/mail/audiences", json={key: value for key, value in payload.items() if value is not None})
14
14
 
15
- def list(self) -> dict[str, Any]:
16
- return self._client.mail_request("GET", "/mail/audiences")
15
+ def list(self, *, brand_id: str | None = None) -> dict[str, Any]:
16
+ params = {"brandId": brand_id} if brand_id else None
17
+ return self._client.mail_request("GET", "/mail/audiences", params=params)
17
18
 
18
19
  def get(self, audience_id: str) -> dict[str, Any]:
19
20
  return self._client.mail_request("GET", f"/mail/audiences/{quote(audience_id, safe='')}")
@@ -58,8 +59,8 @@ class MailCampaignsClient:
58
59
  def create(
59
60
  self,
60
61
  *,
61
- name: str,
62
62
  audience_id: str,
63
+ name: str | None = None,
63
64
  template: str = "",
64
65
  from_: str = "",
65
66
  version_id: str | None = None,
@@ -83,8 +84,9 @@ class MailCampaignsClient:
83
84
  }
84
85
  return self._client.mail_request("POST", "/mail/campaigns", json={key: value for key, value in payload.items() if value is not None})
85
86
 
86
- def list(self) -> dict[str, Any]:
87
- return self._client.mail_request("GET", "/mail/campaigns")
87
+ def list(self, *, brand_id: str | None = None) -> dict[str, Any]:
88
+ params = {"brandId": brand_id} if brand_id else None
89
+ return self._client.mail_request("GET", "/mail/campaigns", params=params)
88
90
 
89
91
  def get(self, campaign_id: str) -> dict[str, Any]:
90
92
  return self._client.mail_request("GET", f"/mail/campaigns/{quote(campaign_id)}")
@@ -79,6 +79,15 @@ class MailWebhooksClient:
79
79
  def rotate_secret(self, webhook_id: str) -> dict[str, Any]:
80
80
  return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/rotate-secret")
81
81
 
82
+ def reveal_secret(self, webhook_id: str) -> dict[str, Any]:
83
+ return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/reveal-secret")
84
+
85
+ def diagnosis(self, webhook_id: str) -> dict[str, Any]:
86
+ return self._client.mail_request("GET", f"/mail/webhooks/{quote(webhook_id)}/diagnosis")
87
+
88
+ def diagnose(self, webhook_id: str) -> dict[str, Any]:
89
+ return self._client.mail_request("POST", f"/mail/webhooks/{quote(webhook_id)}/diagnosis")
90
+
82
91
  def delete(self, webhook_id: str) -> None:
83
92
  self._client.mail_request("DELETE", f"/mail/webhooks/{quote(webhook_id)}")
84
93
 
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+
7
+ def _seg(value: str) -> str:
8
+ return quote(value, safe="")
9
+
10
+
11
+ def _camel(fields: dict[str, Any], names: dict[str, str], label: str) -> dict[str, Any]:
12
+ unknown = fields.keys() - names.keys() - set(names.values())
13
+ if unknown:
14
+ raise ValueError(f"Unknown {label} fields: " + ", ".join(sorted(unknown)))
15
+ return {names.get(key, key): value for key, value in fields.items()}
16
+
17
+
18
+ def _present(payload: dict[str, Any]) -> dict[str, Any]:
19
+ return {key: value for key, value in payload.items() if value is not None}
20
+
21
+
22
+ class MailboxDomainsClient:
23
+ def __init__(self, client: Any) -> None:
24
+ self._client = client
25
+
26
+ def create(self, domain: str) -> dict[str, Any]:
27
+ return self._client.mail_request("POST", "/mail/mailbox-domains", json={"domain": domain})
28
+
29
+ def list(self) -> dict[str, Any]:
30
+ return self._client.mail_request("GET", "/mail/mailbox-domains")
31
+
32
+ def get(self, domain_id: str) -> dict[str, Any]:
33
+ return self._client.mail_request("GET", f"/mail/mailbox-domains/{_seg(domain_id)}")
34
+
35
+ def update(self, domain_id: str, **fields: Any) -> dict[str, Any]:
36
+ names = {"catch_all": "catchAll", "retention_days": "retentionDays", "addon_storage_units": "addonStorageUnits"}
37
+ return self._client.mail_request(
38
+ "PATCH", f"/mail/mailbox-domains/{_seg(domain_id)}", json=_camel(fields, names, "mailbox domain")
39
+ )
40
+
41
+ def verify(self, domain_id: str) -> dict[str, Any]:
42
+ return self._client.mail_request("POST", f"/mail/mailbox-domains/{_seg(domain_id)}/verify")
43
+
44
+ def apply_dns(self, domain_id: str) -> dict[str, Any]:
45
+ return self._client.mail_request("POST", f"/mail/mailbox-domains/{_seg(domain_id)}/dns/apply")
46
+
47
+ def dns_applications(self, domain_id: str) -> dict[str, Any]:
48
+ return self._client.mail_request("GET", f"/mail/mailbox-domains/{_seg(domain_id)}/dns/applications")
49
+
50
+ def delete(self, domain_id: str) -> None:
51
+ self._client.mail_request("DELETE", f"/mail/mailbox-domains/{_seg(domain_id)}")
52
+
53
+
54
+ class MailboxesClient:
55
+ def __init__(self, client: Any) -> None:
56
+ self._client = client
57
+
58
+ def plans(self) -> dict[str, Any]:
59
+ return self._client.mail_request("GET", "/mail/mailbox-plans")
60
+
61
+ def client_settings(self) -> dict[str, Any]:
62
+ return self._client.mail_request("GET", "/mail/mailbox-client-settings")
63
+
64
+ def usage(self) -> dict[str, Any]:
65
+ return self._client.mail_request("GET", "/mail/mailbox-usage")
66
+
67
+ def create(
68
+ self,
69
+ *,
70
+ domain_id: str,
71
+ local_part: str,
72
+ display_name: str | None = None,
73
+ kind: str | None = None,
74
+ owner_user_id: str | None = None,
75
+ owner_email: str | None = None,
76
+ plan: str | None = None,
77
+ quota_bytes: int | None = None,
78
+ ai_enabled: bool | None = None,
79
+ ) -> dict[str, Any]:
80
+ payload = {
81
+ "domainId": domain_id,
82
+ "localPart": local_part,
83
+ "displayName": display_name,
84
+ "kind": kind,
85
+ "ownerUserId": owner_user_id,
86
+ "ownerEmail": owner_email,
87
+ "plan": plan,
88
+ "quotaBytes": quota_bytes,
89
+ "aiEnabled": ai_enabled,
90
+ }
91
+ return self._client.mail_request("POST", "/mail/mailboxes", json=_present(payload))
92
+
93
+ def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
94
+ return self._client.mail_request("GET", "/mail/mailboxes", params=_present({"domainId": domain_id}))
95
+
96
+ def get(self, mailbox_id: str) -> dict[str, Any]:
97
+ return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}")
98
+
99
+ def update(
100
+ self,
101
+ mailbox_id: str,
102
+ *,
103
+ display_name: str | None = None,
104
+ quota_bytes: int | None = None,
105
+ ai_enabled: bool | None = None,
106
+ plan: str | None = None,
107
+ ) -> dict[str, Any]:
108
+ payload = {"displayName": display_name, "quotaBytes": quota_bytes, "aiEnabled": ai_enabled, "plan": plan}
109
+ return self._client.mail_request("PATCH", f"/mail/mailboxes/{_seg(mailbox_id)}", json=_present(payload))
110
+
111
+ def delete(self, mailbox_id: str) -> None:
112
+ self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}")
113
+
114
+ def suspend(self, mailbox_id: str) -> dict[str, Any]:
115
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/suspend")
116
+
117
+ def resume(self, mailbox_id: str) -> dict[str, Any]:
118
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/resume")
119
+
120
+ def resend_invitation(self, mailbox_id: str) -> dict[str, Any]:
121
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/invitation/resend")
122
+
123
+ def reset_password(self, mailbox_id: str) -> dict[str, Any]:
124
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/password-reset")
125
+
126
+ def create_profile_link(self, mailbox_id: str) -> dict[str, Any]:
127
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/mobileconfig")
128
+
129
+ def list_devices(self, mailbox_id: str) -> dict[str, Any]:
130
+ return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/devices")
131
+
132
+ def revoke_device(self, mailbox_id: str, kind: str, device_id: str) -> None:
133
+ self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/devices/{_seg(kind)}/{_seg(device_id)}")
134
+
135
+ def list_members(self, mailbox_id: str) -> dict[str, Any]:
136
+ return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/members")
137
+
138
+ def add_member(self, mailbox_id: str, user_id: str) -> dict[str, Any]:
139
+ return self._client.mail_request("POST", f"/mail/mailboxes/{_seg(mailbox_id)}/members", json={"userId": user_id})
140
+
141
+ def remove_member(self, mailbox_id: str, user_id: str) -> None:
142
+ self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/members/{_seg(user_id)}")
143
+
144
+ def list_app_passwords(self, mailbox_id: str) -> dict[str, Any]:
145
+ return self._client.mail_request("GET", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords")
146
+
147
+ def create_app_password(self, mailbox_id: str, label: str) -> dict[str, Any]:
148
+ return self._client.mail_request(
149
+ "POST", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords", json={"label": label}
150
+ )
151
+
152
+ def revoke_app_password(self, mailbox_id: str, password_id: str) -> None:
153
+ self._client.mail_request("DELETE", f"/mail/mailboxes/{_seg(mailbox_id)}/app-passwords/{_seg(password_id)}")
154
+
155
+
156
+ class MailboxAliasesClient:
157
+ def __init__(self, client: Any) -> None:
158
+ self._client = client
159
+
160
+ def create(
161
+ self, *, mailbox_id: str, local_part: str, domain_id: str | None = None, send_as: bool | None = None
162
+ ) -> dict[str, Any]:
163
+ payload = {"mailboxId": mailbox_id, "localPart": local_part, "domainId": domain_id, "sendAs": send_as}
164
+ return self._client.mail_request("POST", "/mail/aliases", json=_present(payload))
165
+
166
+ def list(self, *, mailbox_id: str | None = None) -> dict[str, Any]:
167
+ return self._client.mail_request("GET", "/mail/aliases", params=_present({"mailboxId": mailbox_id}))
168
+
169
+ def update(self, alias_id: str, *, send_as: bool) -> dict[str, Any]:
170
+ return self._client.mail_request("PATCH", f"/mail/aliases/{_seg(alias_id)}", json={"sendAs": send_as})
171
+
172
+ def delete(self, alias_id: str) -> None:
173
+ self._client.mail_request("DELETE", f"/mail/aliases/{_seg(alias_id)}")
174
+
175
+
176
+ class MailboxGroupsClient:
177
+ def __init__(self, client: Any) -> None:
178
+ self._client = client
179
+
180
+ def create(
181
+ self,
182
+ *,
183
+ domain_id: str,
184
+ local_part: str,
185
+ display_name: str | None = None,
186
+ member_ids: list[str] | None = None,
187
+ moderator_id: str | None = None,
188
+ ) -> dict[str, Any]:
189
+ payload = {
190
+ "domainId": domain_id,
191
+ "localPart": local_part,
192
+ "displayName": display_name,
193
+ "memberIds": member_ids,
194
+ "moderatorId": moderator_id,
195
+ }
196
+ return self._client.mail_request("POST", "/mail/groups", json=_present(payload))
197
+
198
+ def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
199
+ return self._client.mail_request("GET", "/mail/groups", params=_present({"domainId": domain_id}))
200
+
201
+ def get(self, group_id: str) -> dict[str, Any]:
202
+ return self._client.mail_request("GET", f"/mail/groups/{_seg(group_id)}")
203
+
204
+ def update(self, group_id: str, **fields: Any) -> dict[str, Any]:
205
+ names = {"display_name": "displayName", "moderator_id": "moderatorId", "member_ids": "memberIds"}
206
+ return self._client.mail_request("PATCH", f"/mail/groups/{_seg(group_id)}", json=_camel(fields, names, "group"))
207
+
208
+ def delete(self, group_id: str) -> None:
209
+ self._client.mail_request("DELETE", f"/mail/groups/{_seg(group_id)}")
210
+
211
+
212
+ class MailboxRoutesClient:
213
+ def __init__(self, client: Any) -> None:
214
+ self._client = client
215
+
216
+ def create(
217
+ self, *, domain_id: str, pattern: str, targets: list[dict[str, Any]], priority: int | None = None
218
+ ) -> dict[str, Any]:
219
+ payload = {"domainId": domain_id, "pattern": pattern, "targets": targets, "priority": priority}
220
+ return self._client.mail_request("POST", "/mail/routes", json=_present(payload))
221
+
222
+ def list(self, *, domain_id: str | None = None) -> dict[str, Any]:
223
+ return self._client.mail_request("GET", "/mail/routes", params=_present({"domainId": domain_id}))
224
+
225
+ def get(self, route_id: str) -> dict[str, Any]:
226
+ return self._client.mail_request("GET", f"/mail/routes/{_seg(route_id)}")
227
+
228
+ def update(
229
+ self, route_id: str, *, priority: int | None = None, targets: list[dict[str, Any]] | None = None
230
+ ) -> dict[str, Any]:
231
+ return self._client.mail_request(
232
+ "PATCH", f"/mail/routes/{_seg(route_id)}", json=_present({"priority": priority, "targets": targets})
233
+ )
234
+
235
+ def delete(self, route_id: str) -> None:
236
+ self._client.mail_request("DELETE", f"/mail/routes/{_seg(route_id)}")
237
+
238
+ def app_health(self, domain_id: str) -> dict[str, Any]:
239
+ return self._client.mail_request("GET", "/mail/routes/app-health", params={"domainId": domain_id})
240
+
241
+ def retry_deliveries(self, route_id: str) -> dict[str, Any]:
242
+ return self._client.mail_request("POST", f"/mail/routes/{_seg(route_id)}/retry-deliveries")
243
+
244
+
245
+ class MailboxImportsClient:
246
+ def __init__(self, client: Any) -> None:
247
+ self._client = client
248
+
249
+ def start(
250
+ self,
251
+ *,
252
+ domain_id: str,
253
+ source: str,
254
+ accounts: list[dict[str, Any]],
255
+ host: str | None = None,
256
+ port: int | None = None,
257
+ ) -> dict[str, Any]:
258
+ payload = {"domainId": domain_id, "source": source, "accounts": accounts, "host": host, "port": port}
259
+ return self._client.mail_request("POST", "/mail/mailbox-imports", json=_present(payload))
260
+
261
+ def list(self) -> dict[str, Any]:
262
+ return self._client.mail_request("GET", "/mail/mailbox-imports")
263
+
264
+ def get(self, import_id: str) -> dict[str, Any]:
265
+ return self._client.mail_request("GET", f"/mail/mailbox-imports/{_seg(import_id)}")
266
+
267
+ def cancel(self, import_id: str) -> dict[str, Any]:
268
+ return self._client.mail_request("POST", f"/mail/mailbox-imports/{_seg(import_id)}/cancel")
269
+
270
+ def skip(self, import_id: str, mailbox_id: str) -> dict[str, Any]:
271
+ return self._client.mail_request(
272
+ "POST", f"/mail/mailbox-imports/{_seg(import_id)}/mailboxes/{_seg(mailbox_id)}/skip"
273
+ )
274
+
275
+ def switch(self, import_id: str, when: str) -> dict[str, Any]:
276
+ return self._client.mail_request("POST", f"/mail/mailbox-imports/{_seg(import_id)}/switch", json={"when": when})
stackshift/partner.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+
7
+ class PartnerClient:
8
+ def __init__(self, client: Any) -> None:
9
+ self._client = client
10
+
11
+ def overview(self) -> dict[str, Any]:
12
+ return self._client.request("GET", "/partner")
13
+
14
+ def update_settings(self, *, complaint_pause_rate: float, bounce_pause_rate: float) -> dict[str, Any]:
15
+ return self._client.request(
16
+ "PUT", "/partner/settings",
17
+ json={"complaintPauseRate": complaint_pause_rate, "bouncePauseRate": bounce_pause_rate},
18
+ )
19
+
20
+ def purchase_domain(
21
+ self,
22
+ *,
23
+ domain: str,
24
+ years: int,
25
+ merchant_ref: str,
26
+ registrant: dict[str, Any],
27
+ brand: dict[str, Any],
28
+ replies: dict[str, Any],
29
+ idempotency_key: str,
30
+ currency: str | None = None,
31
+ ) -> dict[str, Any]:
32
+ body = {"domain": domain, "years": years, "merchantRef": merchant_ref, "registrant": registrant, "brand": brand, "replies": replies}
33
+ if currency is not None:
34
+ body["currency"] = currency
35
+ return self._client.request("POST", "/partner/domains", headers={"Idempotency-Key": idempotency_key}, json=body)
36
+
37
+ def list_domains(
38
+ self,
39
+ *,
40
+ merchant_ref: str | None = None,
41
+ q: str | None = None,
42
+ attention: bool | None = None,
43
+ limit: int | None = None,
44
+ offset: int | None = None,
45
+ ) -> dict[str, Any]:
46
+ params = {"merchantRef": merchant_ref, "q": q, "attention": "true" if attention else None, "limit": limit, "offset": offset}
47
+ return self._client.request("GET", "/partner/domains", params={key: value for key, value in params.items() if value is not None})
48
+
49
+ def get_domain(self, domain_id: str) -> dict[str, Any]:
50
+ return self._client.request("GET", f"/partner/domains/{quote(domain_id, safe='')}")
51
+
52
+ def retry_domain(self, domain_id: str) -> dict[str, Any]:
53
+ return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/retry")
54
+
55
+ def transfer_out(self, domain_id: str) -> dict[str, Any]:
56
+ return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/transfer-out")
57
+
58
+ def get_diagnosis(self, domain_id: str) -> dict[str, Any]:
59
+ return self._client.request("GET", f"/partner/domains/{quote(domain_id, safe='')}/diagnosis")
60
+
61
+ def restore_dns(self, domain_id: str) -> dict[str, Any]:
62
+ return self._client.request("POST", f"/partner/domains/{quote(domain_id, safe='')}/restore-dns")
63
+
64
+ def resume_brand(self, brand_id: str) -> dict[str, Any]:
65
+ return self._client.request("POST", f"/partner/brands/{quote(brand_id, safe='')}/resume")
66
+
67
+ def get_complaints(self, brand_id: str) -> dict[str, Any]:
68
+ return self._client.request("GET", f"/partner/brands/{quote(brand_id, safe='')}/complaints")
69
+
70
+ def list_complaint_sources(self, brand_id: str) -> list[dict[str, Any]]:
71
+ return self._client.request("GET", f"/partner/brands/{quote(brand_id, safe='')}/complaints/sources")
72
+
73
+ def suppress_never_opened(self, brand_id: str, *, audience_id: str) -> dict[str, Any]:
74
+ return self._client.request(
75
+ "POST", f"/partner/brands/{quote(brand_id, safe='')}/suppressions/bulk",
76
+ json={"audienceId": audience_id, "reason": "never_opened"},
77
+ )
78
+
79
+ def undo_suppression(self, brand_id: str, bulk_id: str) -> dict[str, Any]:
80
+ return self._client.request("POST", f"/partner/brands/{quote(brand_id, safe='')}/suppressions/bulk/{quote(bulk_id, safe='')}/undo")
81
+
82
+ def usage(self, *, start: str | None = None, end: str | None = None) -> dict[str, Any]:
83
+ params = {"from": start, "to": end}
84
+ return self._client.request("GET", "/partner/usage", params={key: value for key, value in params.items() if value is not None})
85
+
86
+ def list_requests(self) -> dict[str, Any]:
87
+ return self._client.request("GET", "/partner/requests")
88
+
89
+ def create_request(self, *, kind: str, message: str | None = None) -> dict[str, Any]:
90
+ body = {"kind": kind}
91
+ if message is not None:
92
+ body["message"] = message
93
+ return self._client.request("POST", "/partner/requests", json=body)
@@ -0,0 +1,40 @@
1
+ """Local catalog validation; server registration and destination policy remain authoritative."""
2
+ import math
3
+ import re
4
+ from urllib.parse import urlsplit
5
+ from .support_actions import _check
6
+
7
+ _RESERVED = {'customer', 'customer_id', 'external_customer_id', 'external_id', 'contact_id', 'workspace_id', 'environment_id', 'inbox_id', 'conversation_id', 'installation_id', 'tenant_id', 'user_id', 'organization_id', 'authorization', 'scope', 'identity', 'credentials', 'secret', 'token'}
8
+ def _endpoint(value):
9
+ if not isinstance(value, str) or len(value) > 2048: return False
10
+ try:
11
+ url = urlsplit(value)
12
+ return url.scheme == 'https' and bool(url.hostname) and not url.username and not url.password and '?' not in value and '#' not in value
13
+ except ValueError: return False
14
+
15
+ def _fields(schema, input_schema):
16
+ _check(type(schema) is dict and len(schema) <= 32, 'invalid_catalog')
17
+ for name, field in schema.items():
18
+ _check(bool(re.fullmatch(r'[a-z][a-z0-9_]{0,63}', name)) and (not input_schema or name not in _RESERVED and not name.startswith(('customer_', 'tenant_', 'auth_'))), 'invalid_catalog')
19
+ _check(type(field) is dict and {'type', 'required'} <= field.keys() and field.keys() <= {'type', 'required', 'max_length', 'minimum', 'maximum', 'enum'} and type(field['required']) is bool, 'invalid_catalog')
20
+ kind = field['type']; values = field.get('enum', [])
21
+ if kind == 'string':
22
+ limit = field.get('max_length')
23
+ _check(type(limit) is int and 1 <= limit <= 16000 and 'minimum' not in field and 'maximum' not in field and type(values) is list and len(values) <= 64 and all(type(v) is str and len(v) <= limit for v in values) and len(set(values)) == len(values), 'invalid_catalog')
24
+ elif kind in ('number', 'integer'):
25
+ bounds = [field[key] for key in ('minimum', 'maximum') if key in field]
26
+ _check(not field.get('max_length') and not values and all(type(n) in (int, float) and abs(n) < math.inf and (kind != 'integer' or int(n) == n and abs(n) <= 9007199254740991) for n in bounds) and field.get('minimum', -math.inf) <= field.get('maximum', math.inf), 'invalid_catalog')
27
+ else:
28
+ _check(kind == 'boolean' and not field.get('max_length') and not values and 'minimum' not in field and 'maximum' not in field, 'invalid_catalog')
29
+
30
+ def validate_action_catalog(version: str, actions: list) -> None:
31
+ _check(isinstance(version, str) and len(version) <= 32 and bool(re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version)) and type(actions) is list and 1 <= len(actions) <= 32, 'invalid_catalog')
32
+ names = set()
33
+ for action in actions:
34
+ _check(type(action) is dict and all(key in action for key in ('name', 'version', 'identity_required', 'risk', 'idempotency_supported', 'safe_retry', 'timeout_seconds', 'endpoint', 'input', 'output')), 'invalid_catalog')
35
+ name = action['name']
36
+ _check(type(name) is str and bool(re.fullmatch(r'[a-z][a-z0-9_.]{0,79}', name)) and name not in names and action['version'] == version and action['identity_required'] is True and action['risk'] in ('read', 'bounded_write', 'customer_change', 'financial', 'security') and type(action['idempotency_supported']) is bool and type(action['safe_retry']) is bool and type(action['timeout_seconds']) is int and 1 <= action['timeout_seconds'] <= 30 and _endpoint(action['endpoint']), 'invalid_catalog')
37
+ _check((action['risk'] == 'read' or action['idempotency_supported'] and _endpoint(action.get('reconciliation_endpoint')) and action.get('verification_operation')) and (not action['safe_retry'] or action['idempotency_supported']) and (not action.get('reconciliation_endpoint') or _endpoint(action['reconciliation_endpoint'])), 'invalid_catalog')
38
+ _fields(action['input'], True); _fields(action['output'], False); names.add(name)
39
+ for action in actions:
40
+ _check(not action.get('verification_operation') or any(a['name'] == action['verification_operation'] and a['risk'] == 'read' for a in actions), 'invalid_catalog')
@@ -0,0 +1,58 @@
1
+ """Durable claims are required; no in-memory fallback or automatic lease takeover."""
2
+ import hashlib
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Callable, Optional, Protocol
6
+ from .support_actions import (ActionProtocolError, REQUEST_CONTEXT, RECEIPT_CONTEXT, encode_receipt,
7
+ sign_action, validate_fields, verify_action)
8
+
9
+ @dataclass(frozen=True)
10
+ class ActionClaim:
11
+ state: str # acquired, completed, running, unknown, conflict
12
+ fence: str = ""
13
+ response: Optional[bytes] = None
14
+
15
+ class ActionDurableStore(Protocol):
16
+ def claim(self, namespace: str, key: str, digest: str) -> ActionClaim: ...
17
+ def complete(self, namespace: str, key: str, fence: str, response: bytes, unknown: bool = False) -> None: ...
18
+
19
+ @dataclass(frozen=True)
20
+ class SignedActionResponse:
21
+ body: bytes
22
+ headers: dict
23
+
24
+ def receive_action(raw: bytes, headers: dict, *, store: ActionDurableStore, execute: Callable,
25
+ reconcile: Callable, verification: dict) -> SignedActionResponse:
26
+ options = dict(verification)
27
+ now = options.setdefault("now", int(time.time()))
28
+ request = verify_action(raw, headers, **options)
29
+ normalized = {name.lower(): value for name, value in headers.items()}
30
+ delivery = normalized["x-stackshift-delivery-id"]
31
+ digest = hashlib.sha256(raw).hexdigest()
32
+ delivery_key = request["installation_id"] + ":" + delivery
33
+ delivery_claim = store.claim("delivery", delivery_key, digest)
34
+ if delivery_claim.state != "acquired":
35
+ raise ActionProtocolError("delivery_replay")
36
+ key = ":".join((request["installation_id"], request["action"]["name"], request["action"]["version"], request["request_id"], request["mode"]))
37
+ claim = store.claim("execution", key, digest)
38
+ if claim.state == "conflict":
39
+ raise ActionProtocolError("idempotency_conflict")
40
+ if claim.state in ("completed", "unknown") and claim.response is not None:
41
+ response = claim.response
42
+ elif claim.state in ("running", "unknown"):
43
+ response = encode_receipt(request["request_id"], "unknown" if claim.state == "unknown" else "accepted", {}, "execution_unknown" if claim.state == "unknown" else "execution_in_progress")
44
+ elif claim.state == "acquired":
45
+ try:
46
+ handler = reconcile if request["mode"] == "reconcile" else execute
47
+ state, result, code = handler(request)
48
+ action = next(a for a in options["actions"] if a["name"] == request["action"]["name"] and a["version"] == request["action"]["version"])
49
+ if state == "succeeded": validate_fields(result, action["output"])
50
+ response = encode_receipt(request["request_id"], state, result, code)
51
+ except Exception:
52
+ response = encode_receipt(request["request_id"], "unknown", {}, "handler_outcome_unknown")
53
+ state = "unknown"
54
+ store.complete("execution", key, claim.fence, response, state == "unknown")
55
+ else:
56
+ raise ActionProtocolError("invalid_store_claim")
57
+ store.complete("delivery", delivery_key, delivery_claim.fence, response)
58
+ return SignedActionResponse(response, sign_action(response, RECEIPT_CONTEXT, options["key_id"], options["secret"], now, delivery))
@@ -0,0 +1,46 @@
1
+ """SQLite durable claims. Keep this database for the complete request/retry horizon."""
2
+ import sqlite3
3
+ import threading
4
+ import uuid
5
+ from .support_actions import ActionProtocolError
6
+ from .support_action_receiver import ActionClaim
7
+
8
+ class SQLiteActionStore:
9
+ def __init__(self, path: str):
10
+ if not path or path == ":memory:" or path.startswith("file:"):
11
+ raise ActionProtocolError("durable_database_required")
12
+ self._lock = threading.Lock()
13
+ self._db = sqlite3.connect(path, timeout=10, check_same_thread=False, isolation_level=None)
14
+ self._db.execute("PRAGMA journal_mode=WAL")
15
+ self._db.execute("PRAGMA synchronous=FULL")
16
+ self._db.execute("""CREATE TABLE IF NOT EXISTS support_action_claims (
17
+ namespace TEXT NOT NULL, claim_key TEXT NOT NULL, digest TEXT NOT NULL,
18
+ state TEXT NOT NULL, fence TEXT NOT NULL, response BLOB,
19
+ PRIMARY KEY(namespace,claim_key))""")
20
+
21
+ def claim(self, namespace: str, key: str, digest: str) -> ActionClaim:
22
+ with self._lock:
23
+ self._db.execute("BEGIN IMMEDIATE")
24
+ try:
25
+ fence = str(uuid.uuid4())
26
+ cursor = self._db.execute("INSERT OR IGNORE INTO support_action_claims VALUES(?,?,?,'running',?,NULL)", (namespace, key, digest, fence))
27
+ if cursor.rowcount == 1:
28
+ result = ActionClaim("acquired", fence)
29
+ else:
30
+ row = self._db.execute("SELECT digest,state,fence,response FROM support_action_claims WHERE namespace=? AND claim_key=?", (namespace, key)).fetchone()
31
+ result = ActionClaim("conflict" if row[0] != digest else row[1], row[2], row[3])
32
+ self._db.execute("COMMIT")
33
+ return result
34
+ except BaseException:
35
+ self._db.execute("ROLLBACK")
36
+ raise
37
+
38
+ def complete(self, namespace: str, key: str, fence: str, response: bytes, unknown: bool = False) -> None:
39
+ with self._lock:
40
+ cursor = self._db.execute("UPDATE support_action_claims SET state=?,response=? WHERE namespace=? AND claim_key=? AND fence=? AND state='running'", ("unknown" if unknown else "completed", response, namespace, key, fence))
41
+ if cursor.rowcount != 1:
42
+ raise ActionProtocolError("fence_lost")
43
+
44
+ def close(self) -> None:
45
+ with self._lock:
46
+ self._db.close()
@@ -0,0 +1,83 @@
1
+ """Support app/v2 registration and signed action protocol types."""
2
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
3
+
4
+ class ActionField(TypedDict, total=False):
5
+ type: Literal["string", "number", "integer", "boolean"]
6
+ required: bool
7
+ max_length: int
8
+ minimum: float
9
+ maximum: float
10
+ enum: List[str]
11
+
12
+ class ActionDescriptor(TypedDict, total=False):
13
+ name: str
14
+ version: str
15
+ endpoint: str
16
+ reconciliation_endpoint: str
17
+ input: Dict[str, ActionField]
18
+ output: Dict[str, ActionField]
19
+ risk: Literal["read", "bounded_write", "customer_change", "financial", "security"]
20
+ identity_required: bool
21
+ idempotency_supported: bool
22
+ safe_retry: bool
23
+ timeout_seconds: int
24
+ verification_operation: str
25
+
26
+ class ActionManifest(TypedDict):
27
+ schema: str
28
+ version: str
29
+ permissions: List[str]
30
+ event_types: List[str]
31
+ placements: List[str]
32
+ webhook_url: str
33
+ description: str
34
+ actions: List[ActionDescriptor]
35
+
36
+ class ActionScope(TypedDict):
37
+ workspace_id: str
38
+ environment_id: str
39
+ inbox_id: str
40
+ conversation_id: str
41
+ contact_id: str
42
+
43
+ class ActionAuthorization(TypedDict, total=False):
44
+ grant_id: str
45
+ revision: int
46
+ mode: Literal["automatic", "customer", "staff"]
47
+
48
+ class ActionRequest(TypedDict, total=False):
49
+ schema: str
50
+ request_id: str
51
+ installation_id: str
52
+ installation_revision: int
53
+ app_version_id: str
54
+ action: Dict[str, str]
55
+ scope: ActionScope
56
+ customer: Dict[str, str]
57
+ arguments: Dict[str, Any]
58
+ authorization: ActionAuthorization
59
+ issued_at: int
60
+ expires_at: int
61
+ mode: Literal["execute", "reconcile"]
62
+ target_request_id: str
63
+
64
+ class ActionReceipt(TypedDict, total=False):
65
+ schema: str
66
+ request_id: str
67
+ state: Literal["accepted", "succeeded", "failed", "unknown"]
68
+ result: Dict[str, Any]
69
+ error_code: str
70
+
71
+ class ActionInstallationInput(TypedDict):
72
+ version_id: str
73
+ environment_id: str
74
+ inbox_id: str
75
+ permissions: List[str]
76
+ action_operations: List[str]
77
+ confirm_consent: bool
78
+ revision: int
79
+
80
+ class ActionInstallationCredentials(TypedDict):
81
+ action_key_id: str
82
+ action_secret: str
83
+ action_operations: List[str]
@@ -0,0 +1,141 @@
1
+ """Signed, bounded Support action envelopes. No network or business execution."""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import math
6
+ import re
7
+ import time
8
+ from typing import Any, Mapping, Sequence
9
+ from .support_action_types import ActionDescriptor
10
+
11
+ REQUEST_CONTEXT = "stackshift.support.action/v1"
12
+ RECEIPT_CONTEXT = "stackshift.support.action-receipt/v1"
13
+ REQUEST_LIMIT = 256 * 1024
14
+ RECEIPT_LIMIT = 64 * 1024
15
+ UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
16
+ KEY = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
17
+ NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,95}$")
18
+ VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
19
+ PREFIX = "x-stackshift-"
20
+
21
+ class ActionProtocolError(ValueError):
22
+ """Stable safe protocol code, without payload/secret material."""
23
+
24
+ def _check(ok: bool, code: str = "invalid_request") -> None:
25
+ if not ok:
26
+ raise ActionProtocolError(code)
27
+
28
+ def _uuid(value: Any) -> bool:
29
+ return isinstance(value, str) and bool(UUID.fullmatch(value)) and value != "00000000-0000-0000-0000-000000000000"
30
+
31
+ def _integer(value: Any) -> bool:
32
+ return type(value) is int and 0 <= value <= 9007199254740991
33
+
34
+ def _object(value: Any, required: Sequence[str], optional: Sequence[str] = ()) -> None:
35
+ _check(type(value) is dict and set(required) <= value.keys() and value.keys() <= set(required) | set(optional))
36
+
37
+ def _unique(pairs):
38
+ result = {}
39
+ for key, value in pairs:
40
+ _check(key not in result)
41
+ result[key] = value
42
+ return result
43
+
44
+ def _decode(raw: bytes):
45
+ try:
46
+ value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique, parse_constant=lambda _: (_ for _ in ()).throw(ActionProtocolError("invalid_request")))
47
+ def bounded(item, depth=0):
48
+ _check(depth <= 32)
49
+ if isinstance(item, dict):
50
+ for child in item.values(): bounded(child, depth + 1)
51
+ elif isinstance(item, list):
52
+ for child in item: bounded(child, depth + 1)
53
+ elif isinstance(item, float): _check(math.isfinite(item))
54
+ bounded(value)
55
+ return value
56
+ except (ValueError, UnicodeError, RecursionError) as error:
57
+ raise ActionProtocolError("invalid_request") from error
58
+
59
+ def sign_action(raw: bytes, context: str, key_id: str, secret: str, timestamp: int, delivery_id: str) -> dict:
60
+ _check(context in (REQUEST_CONTEXT, RECEIPT_CONTEXT) and bool(KEY.fullmatch(key_id)) and _uuid(delivery_id) and _integer(timestamp) and timestamp > 0 and len(secret) >= 32)
61
+ stamp = str(timestamp)
62
+ signed = (context + "\n" + key_id + "\n" + stamp + "\n" + delivery_id + "\n").encode() + raw
63
+ signature = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
64
+ return {PREFIX + "action-key-id": key_id, PREFIX + "timestamp": stamp, PREFIX + "delivery-id": delivery_id, PREFIX + "action-signature": "v1=" + signature}
65
+
66
+ def _verify(raw: bytes, headers: Mapping[str, str], context: str, key_id: str, secret: str, now: int, tolerance: int, limit: int) -> str:
67
+ _check(type(raw) is bytes and 0 < len(raw) <= limit, "body_limit")
68
+ _check(_integer(now) and type(tolerance) is int and 0 < tolerance <= 300)
69
+ normalized = {}
70
+ for name, value in headers.items():
71
+ lower = name.lower()
72
+ _check(lower not in normalized, "invalid_headers")
73
+ normalized[lower] = value
74
+ stamp = normalized.get(PREFIX + "timestamp", "")
75
+ delivery = normalized.get(PREFIX + "delivery-id", "")
76
+ _check(isinstance(stamp, str) and re.fullmatch(r"[1-9][0-9]{0,15}", stamp) is not None, "invalid_headers")
77
+ _check(normalized.get(PREFIX + "action-key-id") == key_id and _uuid(delivery), "binding_mismatch")
78
+ _check(abs(now - int(stamp)) <= tolerance, "stale_signature")
79
+ expected = sign_action(raw, context, key_id, secret, int(stamp), delivery)[PREFIX + "action-signature"]
80
+ provided = normalized.get(PREFIX + "action-signature", "")
81
+ _check(isinstance(provided, str) and bool(re.fullmatch(r"v1=[0-9a-f]{64}", provided)) and hmac.compare_digest(expected, provided), "invalid_signature")
82
+ return delivery
83
+
84
+ def validate_fields(value: dict, fields: Mapping[str, dict]) -> None:
85
+ _check(type(value) is dict and value.keys() <= fields.keys())
86
+ for name, spec in fields.items():
87
+ if name not in value:
88
+ _check(not spec.get("required", False)); continue
89
+ item = value[name]; kind = spec["type"]
90
+ _check((kind == "string" and isinstance(item, str)) or (kind == "boolean" and type(item) is bool) or (kind == "integer" and type(item) is int and abs(item) <= 9007199254740991) or (kind == "number" and type(item) in (int, float) and abs(item) < math.inf))
91
+ if kind == "string":
92
+ _check(len(item) <= spec.get("max_length", 16000))
93
+ _check(not spec.get("enum") or item in spec["enum"])
94
+ if kind in ("number", "integer"):
95
+ _check(item >= spec.get("minimum", -math.inf) and item <= spec.get("maximum", math.inf))
96
+
97
+ def verify_action(raw: bytes, headers: Mapping[str, str], *, key_id: str, secret: str, installation_id: str, actions: Sequence[ActionDescriptor], expected_customer_external_id: str, now: int = None, tolerance: int = 300) -> dict:
98
+ now = int(time.time()) if now is None else now
99
+ _verify(raw, headers, REQUEST_CONTEXT, key_id, secret, now, tolerance, REQUEST_LIMIT)
100
+ body = _decode(raw)
101
+ _object(body, ("schema", "request_id", "installation_id", "installation_revision", "app_version_id", "action", "scope", "customer", "arguments", "authorization", "issued_at", "expires_at", "mode"), ("target_request_id",))
102
+ _check(body["schema"] == REQUEST_CONTEXT and body["installation_id"] == installation_id, "binding_mismatch")
103
+ _check(all(_uuid(body[key]) for key in ("request_id", "installation_id", "app_version_id")))
104
+ _check(_integer(body["installation_revision"]) and body["installation_revision"] > 0)
105
+ _object(body["action"], ("name", "version")); _object(body["scope"], ("workspace_id", "environment_id", "inbox_id", "conversation_id", "contact_id"))
106
+ _check(all(_uuid(value) for value in body["scope"].values()))
107
+ _object(body["customer"], ("external_id",))
108
+ _check(isinstance(expected_customer_external_id, str) and 0 < len(expected_customer_external_id) <= 256 and body["customer"]["external_id"] == expected_customer_external_id, "binding_mismatch")
109
+ auth = body["authorization"]; _object(auth, ("revision", "mode"), ("grant_id",))
110
+ _check(_integer(auth["revision"]) and auth["revision"] > 0 and auth["mode"] in ("automatic", "customer", "staff") and ("grant_id" not in auth or _uuid(auth["grant_id"])))
111
+ issued, expiry = body["issued_at"], body["expires_at"]
112
+ _check(_integer(issued) and _integer(expiry) and issued <= now + tolerance and expiry > now and expiry > issued, "request_expired")
113
+ action = next((item for item in actions if item["name"] == body["action"]["name"] and item["version"] == body["action"]["version"]), None)
114
+ _check(action is not None, "action_not_registered")
115
+ if body["mode"] == "reconcile":
116
+ _check(_uuid(body.get("target_request_id")) and body["target_request_id"] != body["request_id"] and body["arguments"] == {})
117
+ else:
118
+ _check(body["mode"] == "execute" and "target_request_id" not in body)
119
+ validate_fields(body["arguments"], action["input"])
120
+ return body
121
+
122
+ def encode_receipt(request_id: str, state: str, result: dict, error_code: str = None) -> bytes:
123
+ body = {"schema": RECEIPT_CONTEXT, "request_id": request_id, "state": state, "result": result}
124
+ if error_code is not None: body["error_code"] = error_code
125
+ validate_receipt(body)
126
+ raw = json.dumps(body, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode()
127
+ _check(len(raw) <= RECEIPT_LIMIT, "body_limit")
128
+ _decode(raw)
129
+ return raw
130
+
131
+ def validate_receipt(body: dict) -> None:
132
+ _object(body, ("schema", "request_id", "state", "result"), ("error_code",))
133
+ _check(body["schema"] == RECEIPT_CONTEXT and _uuid(body["request_id"]) and body["state"] in ("accepted", "succeeded", "failed", "unknown") and type(body["result"]) is dict)
134
+ _check(body["state"] == "succeeded" or not body["result"])
135
+ _check("error_code" not in body or isinstance(body["error_code"], str) and re.fullmatch(r"[a-z][a-z0-9_]{0,79}", body["error_code"]) is not None)
136
+
137
+ def verify_receipt(raw: bytes, headers: Mapping[str, str], *, key_id: str, secret: str, request_id: str, delivery_id: str, now: int, tolerance: int = 300) -> dict:
138
+ actual = _verify(raw, headers, RECEIPT_CONTEXT, key_id, secret, now, tolerance, RECEIPT_LIMIT)
139
+ body = _decode(raw); validate_receipt(body)
140
+ _check(actual == delivery_id and body["request_id"] == request_id, "binding_mismatch")
141
+ return body
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackshift
3
- Version: 1.2.1
3
+ Version: 1.3.1
4
4
  Summary: Official Python SDK for StackShift.
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -16,6 +16,25 @@ Official Python SDK for StackShift.
16
16
  pip install stackshift
17
17
  ```
18
18
 
19
+ ## Partner platforms
20
+
21
+ Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the `partner:manage` scope (and `mail:manage` to run campaigns) on your server:
22
+
23
+ ```python
24
+ domain = stackshift.partner.purchase_domain(
25
+ domain="merchantshop.com",
26
+ years=1,
27
+ merchant_ref="m_123",
28
+ registrant={"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"},
29
+ brand={"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos, Nigeria"},
30
+ replies={"mode": "forward", "forwardTo": "ada@gmail.com"},
31
+ idempotency_key="order-m_123-1",
32
+ )
33
+ status = stackshift.partner.get_domain(domain["id"])
34
+ ```
35
+
36
+ `partner` also exposes `overview`, `update_settings`, `list_domains` (with `q` and `attention` to find the merchants that need you), `retry_domain`, `get_diagnosis`, `restore_dns`, `transfer_out`, `resume_brand`, `get_complaints`, `list_complaint_sources`, `suppress_never_opened`, `undo_suppression`, `usage`, `list_requests` and `create_request`. `transfer_out` needs the `partner:transfer` scope (or `*`); `partner:manage` alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 `daily_purchase_limit`; staff can raise it). Validation errors are 400, state conflicts are 409. The `brand` and `replies` dictionaries use API camelCase keys.
37
+
19
38
  ## AI credits
20
39
 
21
40
  AI credits are account-scoped. Use the shared client to inspect usage, start a top-up, or select managed billing versus your own provider key:
@@ -101,6 +120,35 @@ valid = stackshift.mail.webhooks.verify_signature(
101
120
 
102
121
  Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.
103
122
 
123
+ ## Mailboxes
124
+
125
+ Host real mailboxes on your own domain. The mailbox owner gets an invitation by email and sets their own password.
126
+
127
+ ```python
128
+ domain = stackshift.mail.mailbox_domains.create("acme.com")
129
+ print(domain["records"])
130
+ stackshift.mail.mailbox_domains.verify(domain["id"])
131
+
132
+ mailbox = stackshift.mail.mailboxes.create(
133
+ domain_id=domain["id"],
134
+ local_part="ada",
135
+ owner_email="ada@personal.example",
136
+ plan="basic",
137
+ )
138
+
139
+ stackshift.mail.aliases.create(mailbox_id=mailbox["id"], local_part="sales", send_as=True)
140
+ stackshift.mail.routes.create(
141
+ domain_id=domain["id"],
142
+ pattern="orders-*",
143
+ targets=[{"kind": "app", "webhookUrl": "https://app.example.com/inbound"}],
144
+ )
145
+
146
+ devices = stackshift.mail.mailboxes.list_devices(mailbox["id"])
147
+ stackshift.mail.mailboxes.suspend(mailbox["id"])
148
+ ```
149
+
150
+ `stackshift.mail.groups` manages team addresses, and `stackshift.mail.mailbox_imports` copies mail from Google, Microsoft or any IMAP server.
151
+
104
152
  ## Send a template
105
153
 
106
154
  Templates are rendered by StackShift servers. The Python SDK only calls the REST API.
@@ -282,3 +330,20 @@ preview = platform.migrations.start({"source_connection_id": connection_id,
282
330
  "destination_bucket_id": bucket_id, "source_prefix": "campaigns/2026", "dry_run": True})
283
331
  report = platform.migrations.report(preview["id"])
284
332
  ```
333
+
334
+ ## Support action receivers (unreleased)
335
+
336
+ `ActionManifest`, `ActionDescriptor`, installation consent types and `validate_action_catalog` describe `stackshift.support.app/v2`. V1 remains actionless. Registered writes require idempotency, a reconciliation endpoint and a registered read verifier; installations explicitly select `action_operations`. These SDK helpers perform no registration or network calls.
337
+
338
+ Use `receive_action(raw_bytes, headers, store=..., execute=..., reconcile=..., verification=...)`. Verification requires `key_id`, `secret` (the installation action secret, not webhook secret), `installation_id`, `actions`, and a trusted `expected_customer_external_id`. Do not derive that expectation from unverified request JSON. Each synchronous handler returns `(state, result_dict, optional_safe_error_code)`. Success output is checked against the registered flat schema. Receipt helpers `encode_receipt`, `sign_action`, and `verify_receipt` are also exported.
339
+
340
+ `SQLiteActionStore('/absolute/path/claims.sqlite')` uses standard-library sqlite3, WAL and FULL synchronization. Never use an in-memory database for restart safety. The runnable stdin receiver in `examples/support_action_receiver.py` intentionally returns `demo_not_implemented`; replace its handlers with authorized customer-bound business transactions. Set `ACTION_KEY_ID`, `ACTION_SECRET`, `ACTION_INSTALLATION_ID`, and trusted `EXPECTED_CUSTOMER_EXTERNAL_ID`:
341
+
342
+ ```sh
343
+ PYTHONPATH=. python examples/support_action_receiver.py /absolute/path/claims.sqlite headers.json actions.json < body.json
344
+ .venv/bin/python -m unittest discover -s tests -p test_support_actions.py -v
345
+ ```
346
+
347
+ Send the returned `body` unchanged with `headers`. Execution retries retain exact original bytes and request ID with fresh delivery IDs. A handler error is stored as unknown; running/unknown work is never dispatched again. Reconciliation is a separate handler with empty arguments and the original target request ID, and must query actual downstream state. Accepted results remain replayable; there is no asynchronous callback endpoint yet. Disk claims do not make remote side effects atomic: use downstream idempotency or one transactional business/receipt store, and preserve uncertain claims for reconciliation/human review. This is a single-host SQLite example; distributed receivers need a shared durable store.
348
+
349
+ See `../support-actions-conformance/README.md` for exact signatures and adapter requirements. Seven action tests pass, including shared vectors, two-connection concurrency, restart and fenced completion. The existing virtualenv is required here because system Python lacks the SDK's declared `requests` dependency. No live installation/provider qualification was performed; unreleased.
@@ -0,0 +1,34 @@
1
+ stackshift/__init__.py,sha256=B_V3evqxubv9l7wQvO5Tq1vTHv5jykhKvIgAZHpW3IE,2756
2
+ stackshift/ai_credits.py,sha256=2u5zGSBo_spHqgYJhtmChaEiBq9jUVaTjR1uprxMpNQ,1142
3
+ stackshift/assets.py,sha256=ElOFd7V6D2ceIprDnBcwXbqK4jnU9YXz63hWTtvnx9E,24127
4
+ stackshift/assets_dam.py,sha256=dz0QgSZuQcwSSYoWFqy1pCx3xgMGYZLc9tCcQE0_kYo,6659
5
+ stackshift/assets_media.py,sha256=S_7TWu6hm5I8WUPMfQsGE9U15mDiYRG4ygXJzWqwbDE,1523
6
+ stackshift/assets_platform.py,sha256=w2Eke0YFXinmtHt1oaZlaiP_J32VymfhVWWLOYrX93M,4125
7
+ stackshift/assets_video.py,sha256=KBIOTsVspHcMltkELsbTmfu-VB6_36dpYQ7HKTqAa2E,2374
8
+ stackshift/assets_workflows.py,sha256=YDGVfzbMW-y5umPWqrHQ9Pp6tGjUC0fnIfNew73V7-c,7274
9
+ stackshift/client.py,sha256=LY7MLo6ljOt1ASNH1HYqi-1k6KEW5pyu6Dz9QFOaLJM,3310
10
+ stackshift/mail.py,sha256=PZCDfaJ8w-sTqVxRAPzGPnxmA_MNHpBH2Jz4ToKUrBY,16561
11
+ stackshift/mail_advanced.py,sha256=sLte3we0Oy3pSuwjl5c4lfELH6gzaSUKug_WZDUB2DE,4363
12
+ stackshift/mail_billing.py,sha256=bxZlcZkhenugW2G6Qy6TQ9At2Xq10e-4w7keNjOkw8w,384
13
+ stackshift/mail_brands.py,sha256=bNvE-thtrGo6iZkeVzuN3x0XQFUZt4fKD_DivMkqeT8,1658
14
+ stackshift/mail_campaigns.py,sha256=KDFlLLOBbwL2pVya2RmgVGRMuoPXvSqxjQqowTrTmTs,5654
15
+ stackshift/mail_events_webhooks.py,sha256=4w74jZx2C2WNt1RLRgXBBDHZATcwOYe3fEC6igkkt9c,6195
16
+ stackshift/mail_exports.py,sha256=sbwBbypS1APv2DnunYmyViEjUHP1PMSrM0tVcbxcits,1054
17
+ stackshift/mail_mailboxes.py,sha256=I5L1C2LyU0MGDZu6VZCh0J7oOqMiCnLSVWlbnkJZxq0,11872
18
+ stackshift/mail_marketing.py,sha256=7uMZQYjU0siMyqFas_i5YWJTrwT9eYMrWElRThxZMr4,1077
19
+ stackshift/mail_migration.py,sha256=j-VvEbJP92ndQ5HMhdu7obJ4S_4gka7oqNRirIvYkyE,1121
20
+ stackshift/mail_reputation.py,sha256=mPvPkZeqcQc5808PhBK7gYsgf4Fk_U19nRxSsJ60MGU,1112
21
+ stackshift/mail_streams.py,sha256=F7iBzHtWhCFw1cTjwPixNJfCvxdjvsDprZQG2A9-NIs,504
22
+ stackshift/mail_tracking.py,sha256=SRcWTI9Otm5fBIY7nJJ-5qZA13aybUuMdh_PqakBpUg,1575
23
+ stackshift/partner.py,sha256=D_oFDXIQ52H4KrPJdbKRTRa6GAZbWLa0IqqUXxn6Lzc,4248
24
+ stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
25
+ stackshift/support_action_manifest.py,sha256=tCm3Qi_R3MidcJBPcsdKE-g8iaXw2xeU2PzxNuAaYbs,4088
26
+ stackshift/support_action_receiver.py,sha256=PJtXpwt6XurE2sYE-mvjOeAhzWLUoBZsejwHGoC_dFI,3228
27
+ stackshift/support_action_sqlite.py,sha256=Ict1yNNbzp2eEGNF8S7QSlKb9-dQIYiFhJ2ETFy57t8,2423
28
+ stackshift/support_action_types.py,sha256=i7xADrXFSIHJaqwcS77LU4jvni5JKrRUzot5WaWpLLU,2213
29
+ stackshift/support_actions.py,sha256=tZYlb8gdE875SKZdzfN01jfEfIQTyH08XThgCYQ820o,8744
30
+ stackshift/workload_security.py,sha256=5JtJH9fX1kqSY3ipXUa79JslUULWsN5_gyn9tTGfzlA,2941
31
+ stackshift-1.3.1.dist-info/METADATA,sha256=6eJPmX60mVa8nb3bCLcFBuG9l-BnjxXJvQoTrc4SXAk,15458
32
+ stackshift-1.3.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
33
+ stackshift-1.3.1.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
34
+ stackshift-1.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,27 +0,0 @@
1
- stackshift/__init__.py,sha256=6w_s5ELWH4aYV4NXysotLKIX1XdHif0ny_3WGaiGZSY,1425
2
- stackshift/ai_credits.py,sha256=2u5zGSBo_spHqgYJhtmChaEiBq9jUVaTjR1uprxMpNQ,1142
3
- stackshift/assets.py,sha256=qjV59NFxQEF6_56GdMZVrN_QadExFbDWbcbiBFYXCt0,23292
4
- stackshift/assets_dam.py,sha256=dz0QgSZuQcwSSYoWFqy1pCx3xgMGYZLc9tCcQE0_kYo,6659
5
- stackshift/assets_media.py,sha256=S_7TWu6hm5I8WUPMfQsGE9U15mDiYRG4ygXJzWqwbDE,1523
6
- stackshift/assets_platform.py,sha256=w2Eke0YFXinmtHt1oaZlaiP_J32VymfhVWWLOYrX93M,4125
7
- stackshift/assets_video.py,sha256=KBIOTsVspHcMltkELsbTmfu-VB6_36dpYQ7HKTqAa2E,2374
8
- stackshift/assets_workflows.py,sha256=6L8_1d9huhfSrKovwP26tfGKc38o0sbSM-75dt__HRE,6950
9
- stackshift/client.py,sha256=4osuRDTVvBoZGKKhYBmZkx-oCAezg9zWxB2olXlnqsM,3220
10
- stackshift/mail.py,sha256=Ny8aEE846vzanT0B8uqyBB0BGm1p4OmERC0zX_6mGPg,15989
11
- stackshift/mail_advanced.py,sha256=sLte3we0Oy3pSuwjl5c4lfELH6gzaSUKug_WZDUB2DE,4363
12
- stackshift/mail_billing.py,sha256=n1SMNTHW5lVs7phJhZBiKw6ok-T3kbW0A3-4uF1w6Yg,232
13
- stackshift/mail_brands.py,sha256=aaOzETtfix0pFpaWBOHYPVrfUZezWqt53ZXJQk1F7mY,1627
14
- stackshift/mail_campaigns.py,sha256=WAMMQxKt7BvsPzJuJE-uCiLFoyJ1RKh_pD3azWINGXA,5424
15
- stackshift/mail_events_webhooks.py,sha256=iVYz_5DvGaE-Acw5uh_JN7P5LWvgfN-2bLnb_z1CMRU,5712
16
- stackshift/mail_exports.py,sha256=sbwBbypS1APv2DnunYmyViEjUHP1PMSrM0tVcbxcits,1054
17
- stackshift/mail_marketing.py,sha256=7uMZQYjU0siMyqFas_i5YWJTrwT9eYMrWElRThxZMr4,1077
18
- stackshift/mail_migration.py,sha256=j-VvEbJP92ndQ5HMhdu7obJ4S_4gka7oqNRirIvYkyE,1121
19
- stackshift/mail_reputation.py,sha256=mPvPkZeqcQc5808PhBK7gYsgf4Fk_U19nRxSsJ60MGU,1112
20
- stackshift/mail_streams.py,sha256=F7iBzHtWhCFw1cTjwPixNJfCvxdjvsDprZQG2A9-NIs,504
21
- stackshift/mail_tracking.py,sha256=SRcWTI9Otm5fBIY7nJJ-5qZA13aybUuMdh_PqakBpUg,1575
22
- stackshift/projects.py,sha256=oYWaE7xNRR6OXuPEwJH-oFFeOmDA9ozq6jJY09ANIHA,498
23
- stackshift/workload_security.py,sha256=5JtJH9fX1kqSY3ipXUa79JslUULWsN5_gyn9tTGfzlA,2941
24
- stackshift-1.2.1.dist-info/METADATA,sha256=vumHC1MKRyxCW5GnMcWFmjmDeK0pZzU3GBWaPUydP_g,10250
25
- stackshift-1.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
26
- stackshift-1.2.1.dist-info/top_level.txt,sha256=qe3uWsu_O9mNtq7bIck9Y8rjTIMrH0kzgAlGFDoRGqs,11
27
- stackshift-1.2.1.dist-info/RECORD,,