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.
Files changed (66) hide show
  1. {devhelm-1.9.0 → devhelm-1.10.0}/PKG-INFO +1 -1
  2. {devhelm-1.9.0 → devhelm-1.10.0}/pyproject.toml +1 -1
  3. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/email.py +130 -6
  4. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/inboxes.py +101 -5
  5. devhelm-1.10.0/tests/test_inbound.py +234 -0
  6. {devhelm-1.9.0 → devhelm-1.10.0}/uv.lock +3 -2
  7. devhelm-1.9.0/tests/test_inbound.py +0 -93
  8. {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/ci.yml +0 -0
  9. {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/release.yml +0 -0
  10. {devhelm-1.9.0 → devhelm-1.10.0}/.github/workflows/spec-check.yml +0 -0
  11. {devhelm-1.9.0 → devhelm-1.10.0}/.gitignore +0 -0
  12. {devhelm-1.9.0 → devhelm-1.10.0}/LICENSE +0 -0
  13. {devhelm-1.9.0 → devhelm-1.10.0}/Makefile +0 -0
  14. {devhelm-1.9.0 → devhelm-1.10.0}/README.md +0 -0
  15. {devhelm-1.9.0 → devhelm-1.10.0}/docs/openapi/monitoring-api.json +0 -0
  16. {devhelm-1.9.0 → devhelm-1.10.0}/scripts/emit_response_enums.py +0 -0
  17. {devhelm-1.9.0 → devhelm-1.10.0}/scripts/inject_strict_config.py +0 -0
  18. {devhelm-1.9.0 → devhelm-1.10.0}/scripts/regen-from.sh +0 -0
  19. {devhelm-1.9.0 → devhelm-1.10.0}/scripts/release.sh +0 -0
  20. {devhelm-1.9.0 → devhelm-1.10.0}/scripts/typegen.sh +0 -0
  21. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/__init__.py +0 -0
  22. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_enums.py +0 -0
  23. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_errors.py +0 -0
  24. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_generated.py +0 -0
  25. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_http.py +0 -0
  26. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_pagination.py +0 -0
  27. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/_validation.py +0 -0
  28. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/client.py +0 -0
  29. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/py.typed +0 -0
  30. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/__init__.py +0 -0
  31. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/alert_channels.py +0 -0
  32. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/api_keys.py +0 -0
  33. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/dependencies.py +0 -0
  34. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/deploy_lock.py +0 -0
  35. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/environments.py +0 -0
  36. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/forensics.py +0 -0
  37. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/incidents.py +0 -0
  38. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/maintenance_windows.py +0 -0
  39. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/monitors.py +0 -0
  40. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/notification_policies.py +0 -0
  41. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/resource_groups.py +0 -0
  42. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/secrets.py +0 -0
  43. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/services.py +0 -0
  44. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/signed_download.py +0 -0
  45. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/status.py +0 -0
  46. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/status_pages.py +0 -0
  47. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/tags.py +0 -0
  48. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/resources/webhooks.py +0 -0
  49. {devhelm-1.9.0 → devhelm-1.10.0}/src/devhelm/types.py +0 -0
  50. {devhelm-1.9.0 → devhelm-1.10.0}/tests/__init__.py +0 -0
  51. {devhelm-1.9.0 → devhelm-1.10.0}/tests/run_sdk.py +0 -0
  52. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_api_keys.py +0 -0
  53. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_client.py +0 -0
  54. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_dependencies.py +0 -0
  55. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_errors.py +0 -0
  56. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_http.py +0 -0
  57. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_inject_strict_config.py +0 -0
  58. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_maintenance_windows.py +0 -0
  59. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_negative_validation.py +0 -0
  60. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_pagination.py +0 -0
  61. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_schemas.py +0 -0
  62. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_services.py +0 -0
  63. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_spec_parity.py +0 -0
  64. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_status_page_maintenance.py +0 -0
  65. {devhelm-1.9.0 → devhelm-1.10.0}/tests/test_typing.py +0 -0
  66. {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.9.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devhelm"
3
- version = "1.9.0"
3
+ version = "1.10.0"
4
4
  description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more"
5
5
  authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
6
6
  license = "MIT"
@@ -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, *, cursor: str | None = None, limit: int | None = None
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={"inbox": self._local_part},
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(self._email._client, DOMAINS, EmailDomainDto)
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(self, page: int, size: int) -> Page[Domain]:
274
- result = fetch_page(self._email._client, DOMAINS, EmailDomainDto, page, size)
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, *, cursor: str | None = None, limit: int | None = None
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(self._client, BASE, WebhookInboxDto)
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(self, page: int, size: int) -> Page[Inbox]:
203
- result = fetch_page(self._client, BASE, WebhookInboxDto, page, size)
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 = 3
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.9.0"
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