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.
- fastsmtp_cli/__init__.py +3 -0
- fastsmtp_cli/client.py +681 -0
- fastsmtp_cli/commands/__init__.py +13 -0
- fastsmtp_cli/commands/auth.py +130 -0
- fastsmtp_cli/commands/config.py +136 -0
- fastsmtp_cli/commands/domains.py +365 -0
- fastsmtp_cli/commands/operations.py +174 -0
- fastsmtp_cli/commands/options.py +84 -0
- fastsmtp_cli/commands/recipients.py +223 -0
- fastsmtp_cli/commands/rules.py +469 -0
- fastsmtp_cli/commands/users.py +178 -0
- fastsmtp_cli/config.py +218 -0
- fastsmtp_cli/main.py +59 -0
- fastsmtp_cli/output.py +700 -0
- fastsmtp_cli-0.6.0.dist-info/METADATA +78 -0
- fastsmtp_cli-0.6.0.dist-info/RECORD +19 -0
- fastsmtp_cli-0.6.0.dist-info/WHEEL +4 -0
- fastsmtp_cli-0.6.0.dist-info/entry_points.txt +2 -0
- fastsmtp_cli-0.6.0.dist-info/licenses/LICENSE +661 -0
fastsmtp_cli/__init__.py
ADDED
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
|
+
)
|