devhelm 1.9.0__tar.gz → 1.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devhelm-1.9.0 → devhelm-1.10.0}/PKG-INFO +1 -1
- {devhelm-1.9.0 → devhelm-1.10.0}/pyproject.toml +1 -1
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/email.py +130 -6
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/inboxes.py +101 -5
- devhelm-1.10.0/tests/test_inbound.py +234 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/uv.lock +3 -2
- devhelm-1.9.0/tests/test_inbound.py +0 -93
- {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/ci.yml +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/release.yml +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/spec-check.yml +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/.gitignore +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/LICENSE +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/Makefile +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/README.md +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/docs/openapi/monitoring-api.json +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/scripts/emit_response_enums.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/scripts/inject_strict_config.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/scripts/regen-from.sh +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/scripts/release.sh +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/scripts/typegen.sh +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/__init__.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_enums.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_errors.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_generated.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_http.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_pagination.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_validation.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/client.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/py.typed +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/__init__.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/alert_channels.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/api_keys.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/dependencies.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/deploy_lock.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/environments.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/forensics.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/incidents.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/maintenance_windows.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/monitors.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/notification_policies.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/resource_groups.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/secrets.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/services.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/signed_download.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/status.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/status_pages.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/tags.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/webhooks.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/types.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/__init__.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/run_sdk.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_api_keys.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_client.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_dependencies.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_errors.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_http.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_inject_strict_config.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_maintenance_windows.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_negative_validation.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_pagination.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_schemas.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_services.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_spec_parity.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_status_page_maintenance.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_typing.py +0 -0
- {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_validation_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devhelm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.0
|
|
4
4
|
Summary: DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more
|
|
5
5
|
Project-URL: Homepage, https://github.com/devhelmhq/sdk-python
|
|
6
6
|
Project-URL: Repository, https://github.com/devhelmhq/sdk-python.git
|
|
@@ -9,11 +9,14 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import builtins
|
|
11
11
|
import secrets
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
from pathlib import Path
|
|
15
|
+
from uuid import UUID
|
|
14
16
|
|
|
15
17
|
import httpx
|
|
16
18
|
|
|
19
|
+
from devhelm._errors import DevhelmValidationError
|
|
17
20
|
from devhelm._generated import (
|
|
18
21
|
CreateEmailDomainRequest,
|
|
19
22
|
EmailDnsRecordDto,
|
|
@@ -51,6 +54,92 @@ from devhelm.resources.signed_download import (
|
|
|
51
54
|
DOMAINS = "/api/v1/email/domains"
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class DomainActivityBucket:
|
|
59
|
+
hour: str
|
|
60
|
+
message_count: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class DomainActivity:
|
|
65
|
+
domain_id: UUID
|
|
66
|
+
buckets: list[DomainActivityBucket]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class MessageSource:
|
|
71
|
+
source: str
|
|
72
|
+
truncated: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _query(**values: str | None) -> dict[str, str] | None:
|
|
76
|
+
params: dict[str, str] = {}
|
|
77
|
+
for key, value in values.items():
|
|
78
|
+
if value is not None:
|
|
79
|
+
params[key] = value
|
|
80
|
+
return params or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _invalid(context: str) -> DevhelmValidationError:
|
|
84
|
+
return DevhelmValidationError(f"Invalid response from {context}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _count(value: object, context: str) -> int:
|
|
88
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
89
|
+
raise _invalid(context)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _activity_row(item: object, context: str) -> DomainActivity:
|
|
94
|
+
if not isinstance(item, dict):
|
|
95
|
+
raise _invalid(context)
|
|
96
|
+
domain_id = item.get("domainId")
|
|
97
|
+
buckets = item.get("buckets")
|
|
98
|
+
if not isinstance(domain_id, str) or not isinstance(buckets, list):
|
|
99
|
+
raise _invalid(context)
|
|
100
|
+
try:
|
|
101
|
+
parsed_id = UUID(domain_id)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise DevhelmValidationError(
|
|
104
|
+
f"Invalid response from {context}", cause=exc
|
|
105
|
+
) from exc
|
|
106
|
+
parsed: list[DomainActivityBucket] = []
|
|
107
|
+
for bucket in buckets:
|
|
108
|
+
if not isinstance(bucket, dict):
|
|
109
|
+
raise _invalid(context)
|
|
110
|
+
hour = bucket.get("hour")
|
|
111
|
+
if not isinstance(hour, str):
|
|
112
|
+
raise _invalid(context)
|
|
113
|
+
parsed.append(
|
|
114
|
+
DomainActivityBucket(
|
|
115
|
+
hour=hour, message_count=_count(bucket.get("messageCount"), context)
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return DomainActivity(domain_id=parsed_id, buckets=parsed)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_activity(raw: object, context: str) -> list[DomainActivity]:
|
|
122
|
+
if not isinstance(raw, dict):
|
|
123
|
+
raise _invalid(context)
|
|
124
|
+
data = raw.get("data")
|
|
125
|
+
if not isinstance(data, list):
|
|
126
|
+
raise _invalid(context)
|
|
127
|
+
return [_activity_row(item, context) for item in data]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_source(raw: object, context: str) -> MessageSource:
|
|
131
|
+
if not isinstance(raw, dict):
|
|
132
|
+
raise _invalid(context)
|
|
133
|
+
data = raw.get("data")
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
raise _invalid(context)
|
|
136
|
+
source = data.get("source")
|
|
137
|
+
truncated = data.get("truncated")
|
|
138
|
+
if not isinstance(source, str) or not isinstance(truncated, bool):
|
|
139
|
+
raise _invalid(context)
|
|
140
|
+
return MessageSource(source=source, truncated=truncated)
|
|
141
|
+
|
|
142
|
+
|
|
54
143
|
def _domain_segment(name: str) -> str:
|
|
55
144
|
return path_param(name).replace("%2E", ".").replace("%2e", ".")
|
|
56
145
|
|
|
@@ -131,6 +220,11 @@ class Message:
|
|
|
131
220
|
self._email._client, f"{self._message_path()}/links", InboundEmailLink
|
|
132
221
|
)
|
|
133
222
|
|
|
223
|
+
def source(self) -> MessageSource:
|
|
224
|
+
"""Clipped RFC822 text. ``truncated`` means the raw object is longer."""
|
|
225
|
+
path = f"{self._message_path()}/source"
|
|
226
|
+
return _read_source(api_get(self._email._client, path), f"GET {path}")
|
|
227
|
+
|
|
134
228
|
def _message_path(self) -> str:
|
|
135
229
|
return (
|
|
136
230
|
f"{DOMAINS}/{_domain_segment(self._domain)}/messages/"
|
|
@@ -148,7 +242,11 @@ class AddressMessages:
|
|
|
148
242
|
self._local_part = local_part
|
|
149
243
|
|
|
150
244
|
def list(
|
|
151
|
-
self,
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
cursor: str | None = None,
|
|
248
|
+
limit: int | None = None,
|
|
249
|
+
q: str | None = None,
|
|
152
250
|
) -> CursorPage[Message]:
|
|
153
251
|
path = f"{DOMAINS}/{_domain_segment(self._domain)}/messages"
|
|
154
252
|
page = fetch_cursor_page(
|
|
@@ -157,7 +255,7 @@ class AddressMessages:
|
|
|
157
255
|
EmailMessageDto,
|
|
158
256
|
cursor,
|
|
159
257
|
limit,
|
|
160
|
-
extra_params=
|
|
258
|
+
extra_params=_query(inbox=self._local_part, q=q),
|
|
161
259
|
)
|
|
162
260
|
return CursorPage(
|
|
163
261
|
data=[Message(self._email, self._domain, row) for row in page.data],
|
|
@@ -266,12 +364,26 @@ class EmailDomains:
|
|
|
266
364
|
def __init__(self, email: Email) -> None:
|
|
267
365
|
self._email = email
|
|
268
366
|
|
|
269
|
-
def list(self) -> list[Domain]:
|
|
270
|
-
rows = fetch_all_pages(
|
|
367
|
+
def list(self, *, search: str | None = None) -> list[Domain]:
|
|
368
|
+
rows = fetch_all_pages(
|
|
369
|
+
self._email._client,
|
|
370
|
+
DOMAINS,
|
|
371
|
+
EmailDomainDto,
|
|
372
|
+
extra_params=_query(search=search),
|
|
373
|
+
)
|
|
271
374
|
return [Domain(self, row) for row in rows]
|
|
272
375
|
|
|
273
|
-
def list_page(
|
|
274
|
-
|
|
376
|
+
def list_page(
|
|
377
|
+
self, page: int, size: int, *, search: str | None = None
|
|
378
|
+
) -> Page[Domain]:
|
|
379
|
+
result = fetch_page(
|
|
380
|
+
self._email._client,
|
|
381
|
+
DOMAINS,
|
|
382
|
+
EmailDomainDto,
|
|
383
|
+
page,
|
|
384
|
+
size,
|
|
385
|
+
extra_params=_query(search=search),
|
|
386
|
+
)
|
|
275
387
|
return Page(
|
|
276
388
|
data=[Domain(self, row) for row in result.data],
|
|
277
389
|
has_next=result.has_next,
|
|
@@ -281,6 +393,18 @@ class EmailDomains:
|
|
|
281
393
|
next_cursor=result.next_cursor,
|
|
282
394
|
)
|
|
283
395
|
|
|
396
|
+
def activity(self, domain_ids: builtins.list[str]) -> builtins.list[DomainActivity]:
|
|
397
|
+
"""Last-24h message counts. An empty id list does not call the API."""
|
|
398
|
+
if not domain_ids:
|
|
399
|
+
return []
|
|
400
|
+
path = f"{DOMAINS}/activity"
|
|
401
|
+
return _read_activity(
|
|
402
|
+
api_get(
|
|
403
|
+
self._email._client, path, params={"domainIds": ",".join(domain_ids)}
|
|
404
|
+
),
|
|
405
|
+
f"GET {path}",
|
|
406
|
+
)
|
|
407
|
+
|
|
284
408
|
def get(self, name: str) -> Domain:
|
|
285
409
|
path = f"{DOMAINS}/{_domain_segment(name)}"
|
|
286
410
|
row = parse_single(
|
|
@@ -6,7 +6,9 @@ capture URL a sender hits, plus ``wait`` for the captured request.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import builtins
|
|
9
10
|
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
10
12
|
from datetime import datetime, timezone
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from uuid import UUID
|
|
@@ -47,6 +49,73 @@ from devhelm.resources.signed_download import (
|
|
|
47
49
|
BASE = "/api/v1/webhook/inboxes"
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class InboxActivityBucket:
|
|
54
|
+
hour: str
|
|
55
|
+
event_count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class InboxActivity:
|
|
60
|
+
inbox_id: UUID
|
|
61
|
+
buckets: list[InboxActivityBucket]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _query(**values: str | None) -> dict[str, str] | None:
|
|
65
|
+
params: dict[str, str] = {}
|
|
66
|
+
for key, value in values.items():
|
|
67
|
+
if value is not None:
|
|
68
|
+
params[key] = value
|
|
69
|
+
return params or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _invalid(context: str) -> DevhelmValidationError:
|
|
73
|
+
return DevhelmValidationError(f"Invalid activity response from {context}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _count(value: object, context: str) -> int:
|
|
77
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
78
|
+
raise _invalid(context)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _activity_row(item: object, context: str) -> InboxActivity:
|
|
83
|
+
if not isinstance(item, dict):
|
|
84
|
+
raise _invalid(context)
|
|
85
|
+
inbox_id = item.get("inboxId")
|
|
86
|
+
buckets = item.get("buckets")
|
|
87
|
+
if not isinstance(inbox_id, str) or not isinstance(buckets, list):
|
|
88
|
+
raise _invalid(context)
|
|
89
|
+
try:
|
|
90
|
+
parsed_id = UUID(inbox_id)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise DevhelmValidationError(
|
|
93
|
+
f"Invalid activity response from {context}", cause=exc
|
|
94
|
+
) from exc
|
|
95
|
+
parsed: list[InboxActivityBucket] = []
|
|
96
|
+
for bucket in buckets:
|
|
97
|
+
if not isinstance(bucket, dict):
|
|
98
|
+
raise _invalid(context)
|
|
99
|
+
hour = bucket.get("hour")
|
|
100
|
+
if not isinstance(hour, str):
|
|
101
|
+
raise _invalid(context)
|
|
102
|
+
parsed.append(
|
|
103
|
+
InboxActivityBucket(
|
|
104
|
+
hour=hour, event_count=_count(bucket.get("eventCount"), context)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return InboxActivity(inbox_id=parsed_id, buckets=parsed)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_activity(raw: object, context: str) -> list[InboxActivity]:
|
|
111
|
+
if not isinstance(raw, dict):
|
|
112
|
+
raise _invalid(context)
|
|
113
|
+
data = raw.get("data")
|
|
114
|
+
if not isinstance(data, list):
|
|
115
|
+
raise _invalid(context)
|
|
116
|
+
return [_activity_row(item, context) for item in data]
|
|
117
|
+
|
|
118
|
+
|
|
50
119
|
class InboxEvents:
|
|
51
120
|
"""Events stored on one inbox."""
|
|
52
121
|
|
|
@@ -55,7 +124,12 @@ class InboxEvents:
|
|
|
55
124
|
self._inbox_id = inbox_id
|
|
56
125
|
|
|
57
126
|
def list(
|
|
58
|
-
self,
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
cursor: str | None = None,
|
|
130
|
+
limit: int | None = None,
|
|
131
|
+
method: str | None = None,
|
|
132
|
+
path: str | None = None,
|
|
59
133
|
) -> CursorPage[ListedEvent]:
|
|
60
134
|
page = fetch_cursor_page(
|
|
61
135
|
self._inboxes._client,
|
|
@@ -63,6 +137,7 @@ class InboxEvents:
|
|
|
63
137
|
WebhookEventDto,
|
|
64
138
|
cursor,
|
|
65
139
|
limit,
|
|
140
|
+
extra_params=_query(method=method, path=path),
|
|
66
141
|
)
|
|
67
142
|
return CursorPage(
|
|
68
143
|
data=[self._inboxes._bind_listed(self._inbox_id, row) for row in page.data],
|
|
@@ -195,12 +270,23 @@ class Inboxes:
|
|
|
195
270
|
def __init__(self, client: httpx.Client) -> None:
|
|
196
271
|
self._client = client
|
|
197
272
|
|
|
198
|
-
def list(self) -> list[Inbox]:
|
|
199
|
-
rows = fetch_all_pages(
|
|
273
|
+
def list(self, *, search: str | None = None) -> list[Inbox]:
|
|
274
|
+
rows = fetch_all_pages(
|
|
275
|
+
self._client, BASE, WebhookInboxDto, extra_params=_query(search=search)
|
|
276
|
+
)
|
|
200
277
|
return [self._bind(row) for row in rows]
|
|
201
278
|
|
|
202
|
-
def list_page(
|
|
203
|
-
|
|
279
|
+
def list_page(
|
|
280
|
+
self, page: int, size: int, *, search: str | None = None
|
|
281
|
+
) -> Page[Inbox]:
|
|
282
|
+
result = fetch_page(
|
|
283
|
+
self._client,
|
|
284
|
+
BASE,
|
|
285
|
+
WebhookInboxDto,
|
|
286
|
+
page,
|
|
287
|
+
size,
|
|
288
|
+
extra_params=_query(search=search),
|
|
289
|
+
)
|
|
204
290
|
return Page(
|
|
205
291
|
data=[self._bind(row) for row in result.data],
|
|
206
292
|
has_next=result.has_next,
|
|
@@ -210,6 +296,16 @@ class Inboxes:
|
|
|
210
296
|
next_cursor=result.next_cursor,
|
|
211
297
|
)
|
|
212
298
|
|
|
299
|
+
def activity(self, inbox_ids: builtins.list[str]) -> builtins.list[InboxActivity]:
|
|
300
|
+
"""Last-24h request counts. An empty id list does not call the API."""
|
|
301
|
+
if not inbox_ids:
|
|
302
|
+
return []
|
|
303
|
+
path = f"{BASE}/activity"
|
|
304
|
+
return _read_activity(
|
|
305
|
+
api_get(self._client, path, params={"inboxIds": ",".join(inbox_ids)}),
|
|
306
|
+
f"GET {path}",
|
|
307
|
+
)
|
|
308
|
+
|
|
213
309
|
def get(self, id: str) -> Inbox:
|
|
214
310
|
path = f"{BASE}/{path_param(id)}"
|
|
215
311
|
return self._bind(
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Inbound inbox and email helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from devhelm import DevhelmApiError
|
|
12
|
+
from devhelm.resources.email import Email
|
|
13
|
+
from devhelm.resources.inboxes import Inboxes
|
|
14
|
+
|
|
15
|
+
INBOX_ID = "550e8400-e29b-41d4-a716-446655440000"
|
|
16
|
+
EVENT_ID = "550e8400-e29b-41d4-a716-446655440001"
|
|
17
|
+
WHEN = "2026-09-24T12:00:00Z"
|
|
18
|
+
|
|
19
|
+
EVENT = {
|
|
20
|
+
"id": EVENT_ID,
|
|
21
|
+
"inboxId": INBOX_ID,
|
|
22
|
+
"receivedAt": WHEN,
|
|
23
|
+
"sizeBytes": 2,
|
|
24
|
+
"headers": {},
|
|
25
|
+
"method": "POST",
|
|
26
|
+
"path": "/",
|
|
27
|
+
"sha256": "abc",
|
|
28
|
+
"body": '{"ok":true}',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
SIGNED = {
|
|
32
|
+
"data": {
|
|
33
|
+
"url": "https://files.example/event",
|
|
34
|
+
"expiresAt": WHEN,
|
|
35
|
+
"filename": "event.bin",
|
|
36
|
+
"contentType": "application/json",
|
|
37
|
+
"sizeBytes": 2,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _json(payload: object, status: int = 200) -> httpx.Response:
|
|
43
|
+
return httpx.Response(status, json=payload)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_inbox_wait_unwraps_event_and_downloads_signed_url(
|
|
47
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
48
|
+
) -> None:
|
|
49
|
+
captured: list[httpx.Request] = []
|
|
50
|
+
|
|
51
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
52
|
+
captured.append(request)
|
|
53
|
+
if request.url.path.endswith("/wait"):
|
|
54
|
+
return _json({"event": EVENT})
|
|
55
|
+
if request.url.path.endswith("/raw"):
|
|
56
|
+
return _json(SIGNED)
|
|
57
|
+
return _json({"message": str(request.url)}, status=404)
|
|
58
|
+
|
|
59
|
+
downloaded: list[str] = []
|
|
60
|
+
|
|
61
|
+
def fake_get(url: str, **_kwargs: Any) -> httpx.Response:
|
|
62
|
+
downloaded.append(url)
|
|
63
|
+
return httpx.Response(200, content=b"\x09\x09")
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(httpx, "get", fake_get)
|
|
66
|
+
client = httpx.Client(
|
|
67
|
+
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
68
|
+
)
|
|
69
|
+
event = Inboxes(client).wait(INBOX_ID, timeout_ms=30_000, http={"method": "POST"})
|
|
70
|
+
assert event.method == "POST"
|
|
71
|
+
assert event.text() == '{"ok":true}'
|
|
72
|
+
assert event.json() == {"ok": True}
|
|
73
|
+
assert event.raw() == b"\x09\x09"
|
|
74
|
+
assert downloaded == ["https://files.example/event"]
|
|
75
|
+
|
|
76
|
+
body = json.loads(captured[0].content)
|
|
77
|
+
assert body["timeoutMs"] == 30_000
|
|
78
|
+
assert "receivedAfter" in body
|
|
79
|
+
assert body["http"] == {"method": "POST"}
|
|
80
|
+
assert all("files.example" not in str(request.url) for request in captured)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
INBOX = {
|
|
84
|
+
"id": INBOX_ID,
|
|
85
|
+
"workspaceId": 2,
|
|
86
|
+
"name": "stripe",
|
|
87
|
+
"status": "active",
|
|
88
|
+
"publicToken": "tok",
|
|
89
|
+
"httpUrl": "https://api.test/api/v1/ingest/tok",
|
|
90
|
+
"httpResponse": {
|
|
91
|
+
"status": 200,
|
|
92
|
+
"headers": {},
|
|
93
|
+
"body": "",
|
|
94
|
+
"contentType": "text/plain",
|
|
95
|
+
"delayMs": 0,
|
|
96
|
+
},
|
|
97
|
+
"cors": True,
|
|
98
|
+
"retentionDays": 3,
|
|
99
|
+
"maxEvents": 10000,
|
|
100
|
+
"createdAt": WHEN,
|
|
101
|
+
"updatedAt": WHEN,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
PAGE = {"hasNext": False, "hasPrev": False, "totalElements": 1, "totalPages": 1}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_inbox_search_event_filter_and_activity() -> None:
|
|
108
|
+
seen: list[httpx.Request] = []
|
|
109
|
+
|
|
110
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
111
|
+
seen.append(request)
|
|
112
|
+
if request.url.path.endswith("/activity"):
|
|
113
|
+
return _json(
|
|
114
|
+
{
|
|
115
|
+
"data": [
|
|
116
|
+
{
|
|
117
|
+
"inboxId": INBOX_ID,
|
|
118
|
+
"buckets": [{"hour": WHEN, "eventCount": 3}],
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
if request.url.path.endswith("/events"):
|
|
124
|
+
return _json({"data": [EVENT], "nextCursor": None, "hasMore": False})
|
|
125
|
+
return _json({"data": [INBOX], **PAGE})
|
|
126
|
+
|
|
127
|
+
client = httpx.Client(
|
|
128
|
+
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
129
|
+
)
|
|
130
|
+
inboxes = Inboxes(client)
|
|
131
|
+
assert inboxes.activity([]) == []
|
|
132
|
+
assert seen == []
|
|
133
|
+
|
|
134
|
+
rows = inboxes.list(search="stripe")
|
|
135
|
+
assert rows[0].name == "stripe"
|
|
136
|
+
assert seen[0].url.params["search"] == "stripe"
|
|
137
|
+
|
|
138
|
+
rows[0].events.list(method="POST", path="/hooks")
|
|
139
|
+
events = next(request for request in seen if request.url.path.endswith("/events"))
|
|
140
|
+
assert events.url.params["method"] == "POST"
|
|
141
|
+
assert events.url.params["path"] == "/hooks"
|
|
142
|
+
|
|
143
|
+
activity = inboxes.activity([INBOX_ID])
|
|
144
|
+
assert activity[0].buckets[0].event_count == 3
|
|
145
|
+
act = next(request for request in seen if request.url.path.endswith("/activity"))
|
|
146
|
+
assert act.url.params["inboxIds"] == INBOX_ID
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_email_query_source_and_domain_activity() -> None:
|
|
150
|
+
domain_id = "550e8400-e29b-41d4-a716-446655440002"
|
|
151
|
+
message_id = "550e8400-e29b-41d4-a716-446655440003"
|
|
152
|
+
seen: list[httpx.Request] = []
|
|
153
|
+
|
|
154
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
155
|
+
seen.append(request)
|
|
156
|
+
if request.url.path.endswith("/source"):
|
|
157
|
+
return _json({"data": {"source": "Subject: code", "truncated": False}})
|
|
158
|
+
if request.url.path.endswith("/activity"):
|
|
159
|
+
return _json(
|
|
160
|
+
{
|
|
161
|
+
"data": [
|
|
162
|
+
{
|
|
163
|
+
"domainId": domain_id,
|
|
164
|
+
"buckets": [{"hour": WHEN, "messageCount": 4}],
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
if "/messages" in request.url.path:
|
|
170
|
+
return _json(
|
|
171
|
+
{
|
|
172
|
+
"data": [
|
|
173
|
+
{
|
|
174
|
+
"id": message_id,
|
|
175
|
+
"domainId": domain_id,
|
|
176
|
+
"receivedAt": WHEN,
|
|
177
|
+
"sizeBytes": 4,
|
|
178
|
+
"headers": {},
|
|
179
|
+
"sha256": "abc",
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
"nextCursor": None,
|
|
183
|
+
"hasMore": False,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return _json(
|
|
187
|
+
{
|
|
188
|
+
"data": [
|
|
189
|
+
{
|
|
190
|
+
"id": domain_id,
|
|
191
|
+
"name": "ws.devhelmmail.com",
|
|
192
|
+
"workspaceId": 2,
|
|
193
|
+
"kind": "assigned",
|
|
194
|
+
"status": "active",
|
|
195
|
+
"mxVerified": True,
|
|
196
|
+
"dnsRecords": [],
|
|
197
|
+
"createdAt": WHEN,
|
|
198
|
+
"updatedAt": WHEN,
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
**PAGE,
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
client = httpx.Client(
|
|
206
|
+
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
207
|
+
)
|
|
208
|
+
email = Email(client)
|
|
209
|
+
address = email.address(domain="ws.devhelmmail.com", label="signup")
|
|
210
|
+
page = address.messages.list(q="code")
|
|
211
|
+
listed = next(request for request in seen if "/messages" in request.url.path)
|
|
212
|
+
assert listed.url.params["q"] == "code"
|
|
213
|
+
assert listed.url.params["inbox"] == address.local_part
|
|
214
|
+
text = page.data[0].source()
|
|
215
|
+
assert text.source == "Subject: code"
|
|
216
|
+
assert text.truncated is False
|
|
217
|
+
|
|
218
|
+
domains = email.domains.list(search="ws")
|
|
219
|
+
assert domains[0].name == "ws.devhelmmail.com"
|
|
220
|
+
activity = email.domains.activity([domain_id])
|
|
221
|
+
assert activity[0].buckets[0].message_count == 4
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_email_wait_timeout_raises() -> None:
|
|
225
|
+
def handler(_request: httpx.Request) -> httpx.Response:
|
|
226
|
+
return _json({"code": "WAIT_TIMEOUT", "message": "timed out"}, status=408)
|
|
227
|
+
|
|
228
|
+
client = httpx.Client(
|
|
229
|
+
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
230
|
+
)
|
|
231
|
+
with pytest.raises(DevhelmApiError) as caught:
|
|
232
|
+
Email(client).wait(to="a@b.devhelmmail.com", timeout_ms=1000)
|
|
233
|
+
assert caught.value.status == 408
|
|
234
|
+
assert caught.value.code == "WAIT_TIMEOUT"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 5
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.14'",
|
|
@@ -315,8 +315,9 @@ wheels = [
|
|
|
315
315
|
|
|
316
316
|
[[package]]
|
|
317
317
|
name = "devhelm"
|
|
318
|
-
version = "1.
|
|
318
|
+
version = "1.10.0"
|
|
319
319
|
source = { editable = "." }
|
|
320
|
+
default-groups = "all"
|
|
320
321
|
dependencies = [
|
|
321
322
|
{ name = "httpx" },
|
|
322
323
|
{ name = "pydantic", extra = ["email"] },
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""Inbound inbox and email helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
from devhelm import DevhelmApiError
|
|
12
|
-
from devhelm.resources.email import Email
|
|
13
|
-
from devhelm.resources.inboxes import Inboxes
|
|
14
|
-
|
|
15
|
-
INBOX_ID = "550e8400-e29b-41d4-a716-446655440000"
|
|
16
|
-
EVENT_ID = "550e8400-e29b-41d4-a716-446655440001"
|
|
17
|
-
WHEN = "2026-09-24T12:00:00Z"
|
|
18
|
-
|
|
19
|
-
EVENT = {
|
|
20
|
-
"id": EVENT_ID,
|
|
21
|
-
"inboxId": INBOX_ID,
|
|
22
|
-
"receivedAt": WHEN,
|
|
23
|
-
"sizeBytes": 2,
|
|
24
|
-
"headers": {},
|
|
25
|
-
"method": "POST",
|
|
26
|
-
"path": "/",
|
|
27
|
-
"sha256": "abc",
|
|
28
|
-
"body": '{"ok":true}',
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
SIGNED = {
|
|
32
|
-
"data": {
|
|
33
|
-
"url": "https://files.example/event",
|
|
34
|
-
"expiresAt": WHEN,
|
|
35
|
-
"filename": "event.bin",
|
|
36
|
-
"contentType": "application/json",
|
|
37
|
-
"sizeBytes": 2,
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _json(payload: object, status: int = 200) -> httpx.Response:
|
|
43
|
-
return httpx.Response(status, json=payload)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_inbox_wait_unwraps_event_and_downloads_signed_url(
|
|
47
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
48
|
-
) -> None:
|
|
49
|
-
captured: list[httpx.Request] = []
|
|
50
|
-
|
|
51
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
52
|
-
captured.append(request)
|
|
53
|
-
if request.url.path.endswith("/wait"):
|
|
54
|
-
return _json({"event": EVENT})
|
|
55
|
-
if request.url.path.endswith("/raw"):
|
|
56
|
-
return _json(SIGNED)
|
|
57
|
-
return _json({"message": str(request.url)}, status=404)
|
|
58
|
-
|
|
59
|
-
downloaded: list[str] = []
|
|
60
|
-
|
|
61
|
-
def fake_get(url: str, **_kwargs: Any) -> httpx.Response:
|
|
62
|
-
downloaded.append(url)
|
|
63
|
-
return httpx.Response(200, content=b"\x09\x09")
|
|
64
|
-
|
|
65
|
-
monkeypatch.setattr(httpx, "get", fake_get)
|
|
66
|
-
client = httpx.Client(
|
|
67
|
-
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
68
|
-
)
|
|
69
|
-
event = Inboxes(client).wait(INBOX_ID, timeout_ms=30_000, http={"method": "POST"})
|
|
70
|
-
assert event.method == "POST"
|
|
71
|
-
assert event.text() == '{"ok":true}'
|
|
72
|
-
assert event.json() == {"ok": True}
|
|
73
|
-
assert event.raw() == b"\x09\x09"
|
|
74
|
-
assert downloaded == ["https://files.example/event"]
|
|
75
|
-
|
|
76
|
-
body = json.loads(captured[0].content)
|
|
77
|
-
assert body["timeoutMs"] == 30_000
|
|
78
|
-
assert "receivedAfter" in body
|
|
79
|
-
assert body["http"] == {"method": "POST"}
|
|
80
|
-
assert all("files.example" not in str(request.url) for request in captured)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_email_wait_timeout_raises() -> None:
|
|
84
|
-
def handler(_request: httpx.Request) -> httpx.Response:
|
|
85
|
-
return _json({"code": "WAIT_TIMEOUT", "message": "timed out"}, status=408)
|
|
86
|
-
|
|
87
|
-
client = httpx.Client(
|
|
88
|
-
transport=httpx.MockTransport(handler), base_url="http://api.test"
|
|
89
|
-
)
|
|
90
|
-
with pytest.raises(DevhelmApiError) as caught:
|
|
91
|
-
Email(client).wait(to="a@b.devhelmmail.com", timeout_ms=1000)
|
|
92
|
-
assert caught.value.status == 408
|
|
93
|
-
assert caught.value.code == "WAIT_TIMEOUT"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|