fastsmtp-cli 0.6.0__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.
@@ -0,0 +1,3 @@
1
+ """FastSMTP CLI - Remote CLI client for FastSMTP server."""
2
+
3
+ __version__ = "0.6.0"
fastsmtp_cli/client.py ADDED
@@ -0,0 +1,681 @@
1
+ """HTTP API client for FastSMTP server.
2
+
3
+ Every path and payload here mirrors a route the server actually serves. The
4
+ contract is enforced by ``fastsmtp-cli/tests/test_api_contract.py``, which drives
5
+ these methods against the server's own OpenAPI document - keep them in step.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Any
10
+ from uuid import UUID
11
+
12
+ import httpx
13
+
14
+ from fastsmtp_cli.config import Profile, get_profile
15
+
16
+
17
+ class _Unset:
18
+ """Sentinel type: a field the caller did not mention at all.
19
+
20
+ Needed by the update methods because the server distinguishes "leave this
21
+ column alone" (field absent from the JSON body) from "reset this column to
22
+ null, i.e. inherit the global default" (field present and null).
23
+ """
24
+
25
+ _instance: "_Unset | None" = None
26
+
27
+ def __new__(cls) -> "_Unset":
28
+ if cls._instance is None:
29
+ cls._instance = super().__new__(cls)
30
+ return cls._instance
31
+
32
+ def __repr__(self) -> str:
33
+ return "UNSET"
34
+
35
+ def __bool__(self) -> bool:
36
+ return False
37
+
38
+
39
+ #: Marker for "field not provided" in update calls.
40
+ UNSET = _Unset()
41
+
42
+ #: A nullable boolean setting on an update call: ``True``/``False`` set it,
43
+ #: ``None`` resets it to inherit the server-wide default, ``UNSET`` leaves it be.
44
+ NullableBool = bool | None | _Unset
45
+
46
+ #: A nullable string column on an update call: a string sets it, ``None``
47
+ #: clears it (sent as JSON null), ``UNSET`` leaves it untouched.
48
+ NullableStr = str | None | _Unset
49
+
50
+
51
+ def _format_detail(detail: Any) -> str:
52
+ """Render an error ``detail`` payload as a single human-readable string.
53
+
54
+ FastAPI returns a string for ``HTTPException`` (for example the 422 raised
55
+ when raw-message preservation is requested without S3 configured) but a list
56
+ of error objects for request-validation failures. Commands print this
57
+ straight to the terminal, so it must always be text.
58
+ """
59
+ if isinstance(detail, str):
60
+ return detail
61
+ if isinstance(detail, list):
62
+ messages = []
63
+ for item in detail:
64
+ if isinstance(item, dict):
65
+ location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
66
+ message = item.get("msg", "invalid value")
67
+ messages.append(f"{location}: {message}" if location else str(message))
68
+ else:
69
+ messages.append(str(item))
70
+ return "; ".join(messages)
71
+ return str(detail)
72
+
73
+
74
+ class APIError(Exception):
75
+ """API request error."""
76
+
77
+ def __init__(self, status_code: int, detail: Any):
78
+ self.status_code = status_code
79
+ self.detail = _format_detail(detail)
80
+ super().__init__(f"API error {status_code}: {self.detail}")
81
+
82
+
83
+ def _flags(**flags: bool) -> dict[str, str]:
84
+ """Query params for boolean flags, sent only when true.
85
+
86
+ Omitting false flags keeps requests byte-identical for older servers that
87
+ do not declare them. An empty dict yields no query string at all, so the
88
+ result can be passed as ``params=`` or merged into other params alike.
89
+ """
90
+ return {name: "true" for name, value in flags.items() if value}
91
+
92
+
93
+ class FastSMTPClient:
94
+ """HTTP client for FastSMTP API."""
95
+
96
+ def __init__(self, profile: Profile | None = None, profile_name: str | None = None):
97
+ """Initialize client with a profile.
98
+
99
+ Args:
100
+ profile: Profile to use directly
101
+ profile_name: Name of profile to load from config
102
+ """
103
+ self.profile = profile or get_profile(profile_name)
104
+ self._client: httpx.Client | None = None
105
+
106
+ def _get_client(self) -> httpx.Client:
107
+ """Get or create HTTP client."""
108
+ if self._client is None:
109
+ headers = {}
110
+ if self.profile.api_key:
111
+ headers["X-API-Key"] = self.profile.api_key
112
+
113
+ self._client = httpx.Client(
114
+ base_url=self.profile.url,
115
+ headers=headers,
116
+ timeout=self.profile.timeout,
117
+ verify=self.profile.verify_ssl,
118
+ )
119
+ return self._client
120
+
121
+ def close(self) -> None:
122
+ """Close the HTTP client."""
123
+ if self._client:
124
+ self._client.close()
125
+ self._client = None
126
+
127
+ def __enter__(self) -> "FastSMTPClient":
128
+ return self
129
+
130
+ def __exit__(self, *args: Any) -> None:
131
+ self.close()
132
+
133
+ def _request(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ **kwargs: Any,
138
+ ) -> Any:
139
+ """Make an API request."""
140
+ client = self._get_client()
141
+ response = client.request(method, path, **kwargs)
142
+
143
+ if response.status_code >= 400:
144
+ try:
145
+ detail = response.json().get("detail", response.text)
146
+ except Exception:
147
+ detail = response.text
148
+ raise APIError(response.status_code, detail)
149
+
150
+ if response.status_code == 204:
151
+ return None
152
+
153
+ return response.json()
154
+
155
+ def get(self, path: str, **kwargs: Any) -> Any:
156
+ """Make a GET request."""
157
+ return self._request("GET", path, **kwargs)
158
+
159
+ def post(self, path: str, **kwargs: Any) -> Any:
160
+ """Make a POST request."""
161
+ return self._request("POST", path, **kwargs)
162
+
163
+ def put(self, path: str, **kwargs: Any) -> Any:
164
+ """Make a PUT request."""
165
+ return self._request("PUT", path, **kwargs)
166
+
167
+ def patch(self, path: str, **kwargs: Any) -> Any:
168
+ """Make a PATCH request."""
169
+ return self._request("PATCH", path, **kwargs)
170
+
171
+ def delete(self, path: str, **kwargs: Any) -> Any:
172
+ """Make a DELETE request."""
173
+ return self._request("DELETE", path, **kwargs)
174
+
175
+ # Health endpoints
176
+
177
+ def health(self) -> dict:
178
+ """Get server health status."""
179
+ return self.get("/api/v1/health")
180
+
181
+ def ready(self) -> dict:
182
+ """Get server readiness status."""
183
+ return self.get("/api/v1/ready")
184
+
185
+ # Auth endpoints
186
+
187
+ def whoami(self) -> dict:
188
+ """Get current authenticated user info."""
189
+ return self.get("/api/v1/auth/me")
190
+
191
+ def list_api_keys(self, include_deleted: bool = False) -> list[dict]:
192
+ """List the caller's active API keys.
193
+
194
+ ``include_deleted`` also returns deleted and retired ones. Keys cannot
195
+ be restored; the listing exists so an operator can audit them.
196
+ """
197
+ return self.get("/api/v1/auth/keys", params=_flags(include_deleted=include_deleted))
198
+
199
+ def create_api_key(
200
+ self,
201
+ name: str,
202
+ scopes: list[str] | None = None,
203
+ expires_at: datetime | None = None,
204
+ ) -> dict:
205
+ """Create a new API key."""
206
+ data: dict[str, Any] = {"name": name}
207
+ if scopes:
208
+ data["scopes"] = scopes
209
+ if expires_at is not None:
210
+ data["expires_at"] = expires_at.isoformat()
211
+ return self.post("/api/v1/auth/keys", json=data)
212
+
213
+ def delete_api_key(self, key_id: UUID | str) -> None:
214
+ """Delete an API key."""
215
+ self.delete(f"/api/v1/auth/keys/{key_id}")
216
+
217
+ def rotate_api_key(self, key_id: UUID | str) -> dict:
218
+ """Rotate an API key."""
219
+ return self.post(f"/api/v1/auth/keys/{key_id}/rotate")
220
+
221
+ # User endpoints (superuser only)
222
+
223
+ def list_users(self, include_deleted: bool = False) -> list[dict]:
224
+ """List all users. ``include_deleted`` also returns soft-deleted ones."""
225
+ return self.get("/api/v1/users", params=_flags(include_deleted=include_deleted))
226
+
227
+ def create_user(
228
+ self,
229
+ username: str,
230
+ email: str | None = None,
231
+ is_superuser: bool = False,
232
+ ) -> dict:
233
+ """Create a new user."""
234
+ data: dict[str, Any] = {"username": username, "is_superuser": is_superuser}
235
+ if email is not None:
236
+ data["email"] = email
237
+ return self.post("/api/v1/users", json=data)
238
+
239
+ def get_user(self, user_id: UUID | str, include_deleted: bool = False) -> dict:
240
+ """Get a user by ID. A soft-deleted user is 404 unless ``include_deleted``."""
241
+ return self.get(f"/api/v1/users/{user_id}", params=_flags(include_deleted=include_deleted))
242
+
243
+ def update_user(
244
+ self,
245
+ user_id: UUID | str,
246
+ username: str | None = None,
247
+ email: NullableStr = UNSET,
248
+ is_active: bool | None = None,
249
+ is_superuser: bool | None = None,
250
+ ) -> dict:
251
+ """Update a user.
252
+
253
+ ``email`` is three-valued: a string sets it, ``None`` clears the
254
+ column (sent as JSON null), ``UNSET`` leaves it untouched. ``username``
255
+ is NOT NULL server-side, so it cannot be cleared, only replaced.
256
+ """
257
+ data: dict[str, Any] = {}
258
+ if username is not None:
259
+ data["username"] = username
260
+ if not isinstance(email, _Unset):
261
+ data["email"] = email
262
+ if is_active is not None:
263
+ data["is_active"] = is_active
264
+ if is_superuser is not None:
265
+ data["is_superuser"] = is_superuser
266
+ return self.put(f"/api/v1/users/{user_id}", json=data)
267
+
268
+ def delete_user(self, user_id: UUID | str, purge: bool = False) -> None:
269
+ """Soft-delete a user; ``purge`` permanently removes an already-deleted one."""
270
+ self.delete(f"/api/v1/users/{user_id}", params=_flags(purge=purge))
271
+
272
+ def restore_user(self, user_id: UUID | str) -> dict:
273
+ """Restore a soft-deleted user. API keys revoked at deletion stay revoked."""
274
+ return self.post(f"/api/v1/users/{user_id}/restore")
275
+
276
+ # Domain endpoints
277
+
278
+ def list_domains(self, include_deleted: bool = False) -> list[dict]:
279
+ """List domains the user has access to.
280
+
281
+ ``include_deleted`` also returns soft-deleted ones: all of them for a
282
+ superuser, only those the caller owns otherwise.
283
+ """
284
+ return self.get("/api/v1/domains", params=_flags(include_deleted=include_deleted))
285
+
286
+ def create_domain(
287
+ self,
288
+ domain_name: str,
289
+ verify_dkim: bool | None = None,
290
+ verify_spf: bool | None = None,
291
+ reject_dkim_fail: bool | None = None,
292
+ reject_spf_fail: bool | None = None,
293
+ preserve_raw_message: bool | None = None,
294
+ ) -> dict:
295
+ """Create a new domain.
296
+
297
+ Any flag left as ``None`` is omitted, so the domain inherits the
298
+ server-wide default for that setting.
299
+ """
300
+ data: dict[str, Any] = {"domain_name": domain_name}
301
+ if verify_dkim is not None:
302
+ data["verify_dkim"] = verify_dkim
303
+ if verify_spf is not None:
304
+ data["verify_spf"] = verify_spf
305
+ if reject_dkim_fail is not None:
306
+ data["reject_dkim_fail"] = reject_dkim_fail
307
+ if reject_spf_fail is not None:
308
+ data["reject_spf_fail"] = reject_spf_fail
309
+ if preserve_raw_message is not None:
310
+ data["preserve_raw_message"] = preserve_raw_message
311
+ return self.post("/api/v1/domains", json=data)
312
+
313
+ def get_domain(self, domain_id: UUID | str, include_deleted: bool = False) -> dict:
314
+ """Get a domain by ID. A soft-deleted domain is 404 unless ``include_deleted``."""
315
+ return self.get(
316
+ f"/api/v1/domains/{domain_id}", params=_flags(include_deleted=include_deleted)
317
+ )
318
+
319
+ def update_domain(
320
+ self,
321
+ domain_id: UUID | str,
322
+ is_enabled: bool | None = None,
323
+ verify_dkim: NullableBool = UNSET,
324
+ verify_spf: NullableBool = UNSET,
325
+ reject_dkim_fail: NullableBool = UNSET,
326
+ reject_spf_fail: NullableBool = UNSET,
327
+ preserve_raw_message: NullableBool = UNSET,
328
+ ) -> dict:
329
+ """Update a domain.
330
+
331
+ The nullable flags are three-valued: ``True``/``False`` pin the setting
332
+ for this domain, ``None`` clears it so the domain inherits the
333
+ server-wide default, and ``UNSET`` leaves it untouched.
334
+ """
335
+ data: dict[str, Any] = {}
336
+ if is_enabled is not None:
337
+ data["is_enabled"] = is_enabled
338
+ for field, value in (
339
+ ("verify_dkim", verify_dkim),
340
+ ("verify_spf", verify_spf),
341
+ ("reject_dkim_fail", reject_dkim_fail),
342
+ ("reject_spf_fail", reject_spf_fail),
343
+ ("preserve_raw_message", preserve_raw_message),
344
+ ):
345
+ if not isinstance(value, _Unset):
346
+ data[field] = value
347
+ return self.put(f"/api/v1/domains/{domain_id}", json=data)
348
+
349
+ def delete_domain(self, domain_id: UUID | str, purge: bool = False) -> None:
350
+ """Soft-delete a domain; ``purge`` permanently removes an already-deleted one."""
351
+ self.delete(f"/api/v1/domains/{domain_id}", params=_flags(purge=purge))
352
+
353
+ def restore_domain(self, domain_id: UUID | str) -> dict:
354
+ """Restore a soft-deleted domain and the recipients deleted with it."""
355
+ return self.post(f"/api/v1/domains/{domain_id}/restore")
356
+
357
+ # Domain members
358
+
359
+ def list_members(self, domain_id: UUID | str) -> list[dict]:
360
+ """List domain members."""
361
+ return self.get(f"/api/v1/domains/{domain_id}/members")
362
+
363
+ def add_member(
364
+ self,
365
+ domain_id: UUID | str,
366
+ user_id: UUID | str,
367
+ role: str = "member",
368
+ ) -> dict:
369
+ """Add a member to a domain."""
370
+ return self.post(
371
+ f"/api/v1/domains/{domain_id}/members",
372
+ json={"user_id": str(user_id), "role": role},
373
+ )
374
+
375
+ def update_member(
376
+ self,
377
+ domain_id: UUID | str,
378
+ user_id: UUID | str,
379
+ role: str,
380
+ ) -> dict:
381
+ """Update a member's role."""
382
+ return self.put(
383
+ f"/api/v1/domains/{domain_id}/members/{user_id}",
384
+ json={"role": role},
385
+ )
386
+
387
+ def remove_member(self, domain_id: UUID | str, user_id: UUID | str) -> None:
388
+ """Remove a member from a domain."""
389
+ self.delete(f"/api/v1/domains/{domain_id}/members/{user_id}")
390
+
391
+ # Recipient endpoints
392
+
393
+ def list_recipients(self, domain_id: UUID | str, include_deleted: bool = False) -> list[dict]:
394
+ """List recipients for a domain. ``include_deleted`` also returns soft-deleted ones."""
395
+ return self.get(
396
+ f"/api/v1/domains/{domain_id}/recipients",
397
+ params=_flags(include_deleted=include_deleted),
398
+ )
399
+
400
+ def create_recipient(
401
+ self,
402
+ domain_id: UUID | str,
403
+ webhook_url: str,
404
+ local_part: str | None = None,
405
+ webhook_headers: dict | None = None,
406
+ ) -> dict:
407
+ """Create a new recipient."""
408
+ data: dict[str, Any] = {"webhook_url": webhook_url}
409
+ if local_part is not None:
410
+ data["local_part"] = local_part
411
+ if webhook_headers:
412
+ data["webhook_headers"] = webhook_headers
413
+ return self.post(f"/api/v1/domains/{domain_id}/recipients", json=data)
414
+
415
+ def get_recipient(
416
+ self, domain_id: UUID | str, recipient_id: UUID | str, include_deleted: bool = False
417
+ ) -> dict:
418
+ """Get a recipient by ID. A soft-deleted recipient is 404 unless ``include_deleted``."""
419
+ return self.get(
420
+ f"/api/v1/domains/{domain_id}/recipients/{recipient_id}",
421
+ params=_flags(include_deleted=include_deleted),
422
+ )
423
+
424
+ def update_recipient(
425
+ self,
426
+ domain_id: UUID | str,
427
+ recipient_id: UUID | str,
428
+ local_part: NullableStr = UNSET,
429
+ webhook_url: str | None = None,
430
+ is_enabled: bool | None = None,
431
+ webhook_headers: dict | None = None,
432
+ ) -> dict:
433
+ """Update a recipient.
434
+
435
+ ``local_part`` is three-valued: a string sets it, ``None`` clears the
436
+ column (sent as JSON null) which turns the recipient into the domain's
437
+ catch-all, ``UNSET`` leaves it untouched.
438
+ """
439
+ data: dict[str, Any] = {}
440
+ if not isinstance(local_part, _Unset):
441
+ data["local_part"] = local_part
442
+ if webhook_url is not None:
443
+ data["webhook_url"] = webhook_url
444
+ if is_enabled is not None:
445
+ data["is_enabled"] = is_enabled
446
+ if webhook_headers is not None:
447
+ data["webhook_headers"] = webhook_headers
448
+ return self.put(f"/api/v1/domains/{domain_id}/recipients/{recipient_id}", json=data)
449
+
450
+ def delete_recipient(
451
+ self, domain_id: UUID | str, recipient_id: UUID | str, purge: bool = False
452
+ ) -> None:
453
+ """Soft-delete a recipient; ``purge`` permanently removes an already-deleted one."""
454
+ self.delete(
455
+ f"/api/v1/domains/{domain_id}/recipients/{recipient_id}",
456
+ params=_flags(purge=purge),
457
+ )
458
+
459
+ def restore_recipient(self, domain_id: UUID | str, recipient_id: UUID | str) -> dict:
460
+ """Restore a soft-deleted recipient. Cancelled deliveries stay cancelled."""
461
+ return self.post(f"/api/v1/domains/{domain_id}/recipients/{recipient_id}/restore")
462
+
463
+ # RuleSet endpoints
464
+
465
+ def list_rulesets(self, domain_id: UUID | str) -> list[dict]:
466
+ """List rulesets for a domain."""
467
+ return self.get(f"/api/v1/domains/{domain_id}/rulesets")
468
+
469
+ def create_ruleset(
470
+ self,
471
+ domain_id: UUID | str,
472
+ name: str,
473
+ priority: int = 0,
474
+ stop_on_match: bool = True,
475
+ ) -> dict:
476
+ """Create a new ruleset."""
477
+ data: dict[str, Any] = {
478
+ "name": name,
479
+ "priority": priority,
480
+ "stop_on_match": stop_on_match,
481
+ }
482
+ return self.post(f"/api/v1/domains/{domain_id}/rulesets", json=data)
483
+
484
+ def get_ruleset(self, domain_id: UUID | str, ruleset_id: UUID | str) -> dict:
485
+ """Get a ruleset by ID, including its rules."""
486
+ return self.get(f"/api/v1/domains/{domain_id}/rulesets/{ruleset_id}")
487
+
488
+ def update_ruleset(
489
+ self,
490
+ domain_id: UUID | str,
491
+ ruleset_id: UUID | str,
492
+ name: str | None = None,
493
+ priority: int | None = None,
494
+ stop_on_match: bool | None = None,
495
+ is_enabled: bool | None = None,
496
+ ) -> dict:
497
+ """Update a ruleset."""
498
+ data: dict[str, Any] = {}
499
+ if name is not None:
500
+ data["name"] = name
501
+ if priority is not None:
502
+ data["priority"] = priority
503
+ if stop_on_match is not None:
504
+ data["stop_on_match"] = stop_on_match
505
+ if is_enabled is not None:
506
+ data["is_enabled"] = is_enabled
507
+ return self.put(f"/api/v1/domains/{domain_id}/rulesets/{ruleset_id}", json=data)
508
+
509
+ def delete_ruleset(self, domain_id: UUID | str, ruleset_id: UUID | str) -> None:
510
+ """Delete a ruleset."""
511
+ self.delete(f"/api/v1/domains/{domain_id}/rulesets/{ruleset_id}")
512
+
513
+ # Rule endpoints
514
+
515
+ def list_rules(self, domain_id: UUID | str, ruleset_id: UUID | str) -> list[dict]:
516
+ """List rules in a ruleset.
517
+
518
+ The server has no standalone rules collection: rules are returned inside
519
+ the ruleset detail response, in ``order`` order.
520
+ """
521
+ ruleset = self.get_ruleset(domain_id, ruleset_id)
522
+ return ruleset.get("rules", [])
523
+
524
+ def create_rule(
525
+ self,
526
+ domain_id: UUID | str,
527
+ ruleset_id: UUID | str,
528
+ field: str,
529
+ operator: str,
530
+ value: str,
531
+ action: str = "forward",
532
+ case_sensitive: bool = False,
533
+ webhook_url_override: str | None = None,
534
+ add_tags: list[str] | None = None,
535
+ preserve_raw: bool = False,
536
+ ) -> dict:
537
+ """Create a new rule.
538
+
539
+ The rule is appended to the end of the ruleset; use
540
+ :meth:`reorder_rules` to change evaluation order.
541
+ """
542
+ data: dict[str, Any] = {
543
+ "field": field,
544
+ "operator": operator,
545
+ "value": value,
546
+ "action": action,
547
+ "case_sensitive": case_sensitive,
548
+ "preserve_raw": preserve_raw,
549
+ }
550
+ if webhook_url_override is not None:
551
+ data["webhook_url_override"] = webhook_url_override
552
+ if add_tags:
553
+ data["add_tags"] = add_tags
554
+ return self.post(f"/api/v1/domains/{domain_id}/rulesets/{ruleset_id}/rules", json=data)
555
+
556
+ def get_rule(
557
+ self,
558
+ domain_id: UUID | str,
559
+ ruleset_id: UUID | str,
560
+ rule_id: UUID | str,
561
+ ) -> dict:
562
+ """Get a single rule out of its ruleset.
563
+
564
+ The server exposes no standalone rule read endpoint, so the rule is
565
+ picked out of the ruleset detail response.
566
+
567
+ Raises:
568
+ APIError: 404 if the ruleset holds no rule with that ID
569
+ """
570
+ for rule in self.list_rules(domain_id, ruleset_id):
571
+ if str(rule.get("id")) == str(rule_id):
572
+ return rule
573
+ raise APIError(404, f"Rule {rule_id} not found in ruleset {ruleset_id}")
574
+
575
+ def update_rule(
576
+ self,
577
+ domain_id: UUID | str,
578
+ rule_id: UUID | str,
579
+ field: str | None = None,
580
+ operator: str | None = None,
581
+ value: str | None = None,
582
+ action: str | None = None,
583
+ case_sensitive: bool | None = None,
584
+ webhook_url_override: NullableStr = UNSET,
585
+ add_tags: list[str] | None = None,
586
+ preserve_raw: bool | None = None,
587
+ ) -> dict:
588
+ """Update a rule.
589
+
590
+ ``webhook_url_override`` is three-valued: a string sets it, ``None``
591
+ clears the override (sent as JSON null) so the rule falls back to the
592
+ recipient's webhook URL, ``UNSET`` leaves it untouched.
593
+ """
594
+ data: dict[str, Any] = {}
595
+ if field is not None:
596
+ data["field"] = field
597
+ if operator is not None:
598
+ data["operator"] = operator
599
+ if value is not None:
600
+ data["value"] = value
601
+ if action is not None:
602
+ data["action"] = action
603
+ if case_sensitive is not None:
604
+ data["case_sensitive"] = case_sensitive
605
+ if not isinstance(webhook_url_override, _Unset):
606
+ data["webhook_url_override"] = webhook_url_override
607
+ if add_tags is not None:
608
+ data["add_tags"] = add_tags
609
+ if preserve_raw is not None:
610
+ data["preserve_raw"] = preserve_raw
611
+ return self.put(f"/api/v1/domains/{domain_id}/rules/{rule_id}", json=data)
612
+
613
+ def delete_rule(self, domain_id: UUID | str, rule_id: UUID | str) -> None:
614
+ """Delete a rule."""
615
+ self.delete(f"/api/v1/domains/{domain_id}/rules/{rule_id}")
616
+
617
+ def reorder_rules(
618
+ self,
619
+ domain_id: UUID | str,
620
+ ruleset_id: UUID | str,
621
+ rule_ids: list[str],
622
+ ) -> dict:
623
+ """Set the evaluation order of a ruleset's rules."""
624
+ return self.post(
625
+ f"/api/v1/domains/{domain_id}/rulesets/{ruleset_id}/reorder",
626
+ json={"rule_ids": [str(rule_id) for rule_id in rule_ids]},
627
+ )
628
+
629
+ # Delivery log endpoints
630
+
631
+ def list_delivery_logs(
632
+ self,
633
+ domain_id: UUID | str,
634
+ status: str | None = None,
635
+ message_id: str | None = None,
636
+ limit: int = 50,
637
+ offset: int = 0,
638
+ include_deleted: bool = False,
639
+ ) -> list[dict]:
640
+ """List delivery logs for a domain.
641
+
642
+ ``include_deleted`` resolves a soft-deleted domain too (owner or
643
+ superuser only), so its history stays readable after the delete.
644
+ """
645
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
646
+ if status:
647
+ params["status"] = status
648
+ if message_id:
649
+ params["message_id"] = message_id
650
+ params.update(_flags(include_deleted=include_deleted))
651
+ return self.get(f"/api/v1/domains/{domain_id}/delivery-log", params=params)
652
+
653
+ def get_delivery_log(self, log_id: UUID | str) -> dict:
654
+ """Get a delivery log entry."""
655
+ return self.get(f"/api/v1/delivery-log/{log_id}")
656
+
657
+ def retry_delivery(self, log_id: UUID | str) -> dict:
658
+ """Retry a failed delivery."""
659
+ return self.post(f"/api/v1/delivery-log/{log_id}/retry")
660
+
661
+ # Test webhook
662
+
663
+ def test_webhook(
664
+ self,
665
+ webhook_url: str,
666
+ from_address: str = "test@example.com",
667
+ to_address: str = "recipient@example.com",
668
+ subject: str = "Test Email",
669
+ body: str = "This is a test email from FastSMTP.",
670
+ ) -> dict:
671
+ """Test a webhook URL."""
672
+ return self.post(
673
+ "/api/v1/test-webhook",
674
+ json={
675
+ "webhook_url": webhook_url,
676
+ "from_address": from_address,
677
+ "to_address": to_address,
678
+ "subject": subject,
679
+ "body": body,
680
+ },
681
+ )
@@ -0,0 +1,13 @@
1
+ """CLI commands module."""
2
+
3
+ from fastsmtp_cli.commands import auth, config, domains, operations, recipients, rules, users
4
+
5
+ __all__ = [
6
+ "auth",
7
+ "config",
8
+ "domains",
9
+ "operations",
10
+ "recipients",
11
+ "rules",
12
+ "users",
13
+ ]