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
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""RuleSet and rule management commands.
|
|
2
|
+
|
|
3
|
+
Rulesets and rules are nested under a domain on the server, so every command
|
|
4
|
+
here takes the domain ID first. Rules have no name, no per-rule ``priority`` and
|
|
5
|
+
no ``is_enabled``: their evaluation order is the ruleset's ``order``, changed
|
|
6
|
+
with ``fsmtp rules rule reorder``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from fastsmtp_cli.client import APIError, FastSMTPClient
|
|
14
|
+
from fastsmtp_cli.commands.options import clearable_str
|
|
15
|
+
from fastsmtp_cli.output import (
|
|
16
|
+
print_error,
|
|
17
|
+
print_rule,
|
|
18
|
+
print_rules_table,
|
|
19
|
+
print_ruleset,
|
|
20
|
+
print_rulesets_table,
|
|
21
|
+
print_success,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="RuleSet and rule management")
|
|
25
|
+
|
|
26
|
+
# Mirrors fastsmtp.schemas.rule - the server rejects anything else.
|
|
27
|
+
VALID_FIELDS = (
|
|
28
|
+
"from",
|
|
29
|
+
"to",
|
|
30
|
+
"subject",
|
|
31
|
+
"body",
|
|
32
|
+
"has_attachment",
|
|
33
|
+
"dkim_result",
|
|
34
|
+
"spf_result",
|
|
35
|
+
)
|
|
36
|
+
FIELD_HEADER_PREFIX = "header:"
|
|
37
|
+
VALID_OPERATORS = ("equals", "contains", "regex", "starts_with", "ends_with", "exists")
|
|
38
|
+
VALID_ACTIONS = ("forward", "drop", "tag", "quarantine")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_field(field: str | None) -> None:
|
|
42
|
+
"""Reject fields the server would reject, before spending a round-trip."""
|
|
43
|
+
if field is None or field in VALID_FIELDS or field.startswith(FIELD_HEADER_PREFIX):
|
|
44
|
+
return
|
|
45
|
+
print_error(
|
|
46
|
+
f"Invalid field. Must be one of: {', '.join(VALID_FIELDS)}, "
|
|
47
|
+
f"or {FIELD_HEADER_PREFIX}X-Header-Name"
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_operator(operator: str | None) -> None:
|
|
53
|
+
"""Reject operators the server would reject."""
|
|
54
|
+
if operator is None or operator in VALID_OPERATORS:
|
|
55
|
+
return
|
|
56
|
+
print_error(f"Invalid operator. Must be one of: {', '.join(VALID_OPERATORS)}")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_action(action: str | None) -> None:
|
|
61
|
+
"""Reject actions the server would reject."""
|
|
62
|
+
if action is None or action in VALID_ACTIONS:
|
|
63
|
+
return
|
|
64
|
+
print_error(f"Invalid action. Must be one of: {', '.join(VALID_ACTIONS)}")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# RuleSet commands
|
|
69
|
+
@app.command("list")
|
|
70
|
+
def list_rulesets(
|
|
71
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
72
|
+
profile: Annotated[
|
|
73
|
+
str | None,
|
|
74
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
75
|
+
] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""List rulesets for a domain."""
|
|
78
|
+
try:
|
|
79
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
80
|
+
rulesets = client.list_rulesets(domain_id)
|
|
81
|
+
if not rulesets:
|
|
82
|
+
print_error("No rulesets found")
|
|
83
|
+
return
|
|
84
|
+
print_rulesets_table(rulesets)
|
|
85
|
+
except APIError as e:
|
|
86
|
+
print_error(e.detail)
|
|
87
|
+
raise typer.Exit(1) from e
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("get")
|
|
91
|
+
def get_ruleset(
|
|
92
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
93
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
94
|
+
profile: Annotated[
|
|
95
|
+
str | None,
|
|
96
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
97
|
+
] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Get ruleset details with rules."""
|
|
100
|
+
try:
|
|
101
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
102
|
+
ruleset = client.get_ruleset(domain_id, ruleset_id)
|
|
103
|
+
print_ruleset(ruleset)
|
|
104
|
+
except APIError as e:
|
|
105
|
+
print_error(e.detail)
|
|
106
|
+
raise typer.Exit(1) from e
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("create")
|
|
110
|
+
def create_ruleset(
|
|
111
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
112
|
+
name: Annotated[str, typer.Argument(help="RuleSet name")],
|
|
113
|
+
priority: Annotated[
|
|
114
|
+
int,
|
|
115
|
+
typer.Option("--priority", "-P", help="Priority (higher = first)"),
|
|
116
|
+
] = 0,
|
|
117
|
+
stop_on_match: Annotated[
|
|
118
|
+
bool,
|
|
119
|
+
typer.Option(
|
|
120
|
+
"--stop-on-match/--no-stop-on-match",
|
|
121
|
+
help="Stop evaluating later rulesets once a rule here matches",
|
|
122
|
+
),
|
|
123
|
+
] = True,
|
|
124
|
+
profile: Annotated[
|
|
125
|
+
str | None,
|
|
126
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
127
|
+
] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Create a new ruleset."""
|
|
130
|
+
try:
|
|
131
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
132
|
+
ruleset = client.create_ruleset(
|
|
133
|
+
domain_id=domain_id,
|
|
134
|
+
name=name,
|
|
135
|
+
priority=priority,
|
|
136
|
+
stop_on_match=stop_on_match,
|
|
137
|
+
)
|
|
138
|
+
print_success(f"RuleSet '{name}' created")
|
|
139
|
+
print_ruleset(ruleset)
|
|
140
|
+
except APIError as e:
|
|
141
|
+
print_error(e.detail)
|
|
142
|
+
raise typer.Exit(1) from e
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("update")
|
|
146
|
+
def update_ruleset(
|
|
147
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
148
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
149
|
+
name: Annotated[
|
|
150
|
+
str | None,
|
|
151
|
+
typer.Option("--name", "-n", help="New name"),
|
|
152
|
+
] = None,
|
|
153
|
+
priority: Annotated[
|
|
154
|
+
int | None,
|
|
155
|
+
typer.Option("--priority", "-P", help="Priority"),
|
|
156
|
+
] = None,
|
|
157
|
+
stop_on_match: Annotated[
|
|
158
|
+
bool | None,
|
|
159
|
+
typer.Option("--stop-on-match/--no-stop-on-match", help="Stop on match"),
|
|
160
|
+
] = None,
|
|
161
|
+
enabled: Annotated[
|
|
162
|
+
bool | None,
|
|
163
|
+
typer.Option("--enabled/--disabled", help="Enable or disable ruleset"),
|
|
164
|
+
] = None,
|
|
165
|
+
profile: Annotated[
|
|
166
|
+
str | None,
|
|
167
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
168
|
+
] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Update a ruleset."""
|
|
171
|
+
if all(value is None for value in [name, priority, stop_on_match, enabled]):
|
|
172
|
+
print_error("At least one option must be provided")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
177
|
+
ruleset = client.update_ruleset(
|
|
178
|
+
domain_id=domain_id,
|
|
179
|
+
ruleset_id=ruleset_id,
|
|
180
|
+
name=name,
|
|
181
|
+
priority=priority,
|
|
182
|
+
stop_on_match=stop_on_match,
|
|
183
|
+
is_enabled=enabled,
|
|
184
|
+
)
|
|
185
|
+
print_success("RuleSet updated")
|
|
186
|
+
print_ruleset(ruleset)
|
|
187
|
+
except APIError as e:
|
|
188
|
+
print_error(e.detail)
|
|
189
|
+
raise typer.Exit(1) from e
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command("delete")
|
|
193
|
+
def delete_ruleset(
|
|
194
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
195
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
196
|
+
force: Annotated[
|
|
197
|
+
bool,
|
|
198
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
199
|
+
] = False,
|
|
200
|
+
profile: Annotated[
|
|
201
|
+
str | None,
|
|
202
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
203
|
+
] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Delete a ruleset."""
|
|
206
|
+
if not force:
|
|
207
|
+
confirm = typer.confirm(f"Delete ruleset {ruleset_id}?")
|
|
208
|
+
if not confirm:
|
|
209
|
+
raise typer.Exit(0)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
213
|
+
client.delete_ruleset(domain_id, ruleset_id)
|
|
214
|
+
print_success(f"RuleSet {ruleset_id} deleted")
|
|
215
|
+
except APIError as e:
|
|
216
|
+
print_error(e.detail)
|
|
217
|
+
raise typer.Exit(1) from e
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Rule subcommands
|
|
221
|
+
rule_app = typer.Typer(help="Rule management within a ruleset")
|
|
222
|
+
app.add_typer(rule_app, name="rule")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@rule_app.command("list")
|
|
226
|
+
def list_rules(
|
|
227
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
228
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
229
|
+
profile: Annotated[
|
|
230
|
+
str | None,
|
|
231
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
232
|
+
] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""List rules in a ruleset."""
|
|
235
|
+
try:
|
|
236
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
237
|
+
rules = client.list_rules(domain_id, ruleset_id)
|
|
238
|
+
if not rules:
|
|
239
|
+
print_error("No rules found")
|
|
240
|
+
return
|
|
241
|
+
print_rules_table(rules)
|
|
242
|
+
except APIError as e:
|
|
243
|
+
print_error(e.detail)
|
|
244
|
+
raise typer.Exit(1) from e
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@rule_app.command("get")
|
|
248
|
+
def get_rule(
|
|
249
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
250
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
251
|
+
rule_id: Annotated[str, typer.Argument(help="Rule ID")],
|
|
252
|
+
profile: Annotated[
|
|
253
|
+
str | None,
|
|
254
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
255
|
+
] = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Get rule details."""
|
|
258
|
+
try:
|
|
259
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
260
|
+
rule = client.get_rule(domain_id, ruleset_id, rule_id)
|
|
261
|
+
print_rule(rule)
|
|
262
|
+
except APIError as e:
|
|
263
|
+
print_error(e.detail)
|
|
264
|
+
raise typer.Exit(1) from e
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@rule_app.command("create")
|
|
268
|
+
def create_rule(
|
|
269
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
270
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
271
|
+
field: Annotated[
|
|
272
|
+
str,
|
|
273
|
+
typer.Option("--field", "-F", help="Field to match", prompt=True),
|
|
274
|
+
],
|
|
275
|
+
operator: Annotated[
|
|
276
|
+
str,
|
|
277
|
+
typer.Option(
|
|
278
|
+
"--operator",
|
|
279
|
+
"-O",
|
|
280
|
+
help="Operator (equals, contains, regex, starts_with, ends_with, exists)",
|
|
281
|
+
prompt=True,
|
|
282
|
+
),
|
|
283
|
+
],
|
|
284
|
+
value: Annotated[
|
|
285
|
+
str,
|
|
286
|
+
typer.Option("--value", "-V", help="Value to match", prompt=True),
|
|
287
|
+
],
|
|
288
|
+
action: Annotated[
|
|
289
|
+
str,
|
|
290
|
+
typer.Option("--action", "-a", help="Action (forward, drop, tag, quarantine)"),
|
|
291
|
+
] = "forward",
|
|
292
|
+
case_sensitive: Annotated[
|
|
293
|
+
bool,
|
|
294
|
+
typer.Option("--case-sensitive/--ignore-case", help="Match case sensitively"),
|
|
295
|
+
] = False,
|
|
296
|
+
webhook_url: Annotated[
|
|
297
|
+
str | None,
|
|
298
|
+
typer.Option("--webhook-url", "-w", help="Override the webhook URL for this rule"),
|
|
299
|
+
] = None,
|
|
300
|
+
tags: Annotated[
|
|
301
|
+
list[str] | None,
|
|
302
|
+
typer.Option("--tag", "-t", help="Tag to add when the rule matches (repeatable)"),
|
|
303
|
+
] = None,
|
|
304
|
+
preserve_raw: Annotated[
|
|
305
|
+
bool,
|
|
306
|
+
typer.Option(
|
|
307
|
+
"--preserve-raw/--no-preserve-raw",
|
|
308
|
+
help="Preserve the raw MIME message in S3 when this rule matches",
|
|
309
|
+
),
|
|
310
|
+
] = False,
|
|
311
|
+
profile: Annotated[
|
|
312
|
+
str | None,
|
|
313
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
314
|
+
] = None,
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Create a new rule, appended to the end of the ruleset."""
|
|
317
|
+
_validate_field(field)
|
|
318
|
+
_validate_operator(operator)
|
|
319
|
+
_validate_action(action)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
323
|
+
rule = client.create_rule(
|
|
324
|
+
domain_id=domain_id,
|
|
325
|
+
ruleset_id=ruleset_id,
|
|
326
|
+
field=field,
|
|
327
|
+
operator=operator,
|
|
328
|
+
value=value,
|
|
329
|
+
action=action,
|
|
330
|
+
case_sensitive=case_sensitive,
|
|
331
|
+
webhook_url_override=webhook_url,
|
|
332
|
+
add_tags=tags,
|
|
333
|
+
preserve_raw=preserve_raw,
|
|
334
|
+
)
|
|
335
|
+
print_success("Rule created")
|
|
336
|
+
print_rule(rule)
|
|
337
|
+
except APIError as e:
|
|
338
|
+
print_error(e.detail)
|
|
339
|
+
raise typer.Exit(1) from e
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@rule_app.command("update")
|
|
343
|
+
def update_rule(
|
|
344
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
345
|
+
rule_id: Annotated[str, typer.Argument(help="Rule ID")],
|
|
346
|
+
field: Annotated[
|
|
347
|
+
str | None,
|
|
348
|
+
typer.Option("--field", "-F", help="Field to match"),
|
|
349
|
+
] = None,
|
|
350
|
+
operator: Annotated[
|
|
351
|
+
str | None,
|
|
352
|
+
typer.Option("--operator", "-O", help="Operator"),
|
|
353
|
+
] = None,
|
|
354
|
+
value: Annotated[
|
|
355
|
+
str | None,
|
|
356
|
+
typer.Option("--value", "-V", help="Value to match"),
|
|
357
|
+
] = None,
|
|
358
|
+
action: Annotated[
|
|
359
|
+
str | None,
|
|
360
|
+
typer.Option("--action", "-a", help="Action"),
|
|
361
|
+
] = None,
|
|
362
|
+
case_sensitive: Annotated[
|
|
363
|
+
bool | None,
|
|
364
|
+
typer.Option("--case-sensitive/--ignore-case", help="Match case sensitively"),
|
|
365
|
+
] = None,
|
|
366
|
+
webhook_url: Annotated[
|
|
367
|
+
str | None,
|
|
368
|
+
typer.Option(
|
|
369
|
+
"--webhook-url",
|
|
370
|
+
"-w",
|
|
371
|
+
help="Override the webhook URL for this rule; pass '' to remove the override",
|
|
372
|
+
),
|
|
373
|
+
] = None,
|
|
374
|
+
tags: Annotated[
|
|
375
|
+
list[str] | None,
|
|
376
|
+
typer.Option("--tag", "-t", help="Tags to add when the rule matches (replaces existing)"),
|
|
377
|
+
] = None,
|
|
378
|
+
preserve_raw: Annotated[
|
|
379
|
+
bool | None,
|
|
380
|
+
typer.Option(
|
|
381
|
+
"--preserve-raw/--no-preserve-raw",
|
|
382
|
+
help="Preserve the raw MIME message in S3 when this rule matches",
|
|
383
|
+
),
|
|
384
|
+
] = None,
|
|
385
|
+
profile: Annotated[
|
|
386
|
+
str | None,
|
|
387
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
388
|
+
] = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Update a rule."""
|
|
391
|
+
options = [field, operator, value, action, case_sensitive, webhook_url, tags, preserve_raw]
|
|
392
|
+
if all(option is None for option in options):
|
|
393
|
+
print_error("At least one option must be provided")
|
|
394
|
+
raise typer.Exit(1)
|
|
395
|
+
|
|
396
|
+
_validate_field(field)
|
|
397
|
+
_validate_operator(operator)
|
|
398
|
+
_validate_action(action)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
402
|
+
rule = client.update_rule(
|
|
403
|
+
domain_id=domain_id,
|
|
404
|
+
rule_id=rule_id,
|
|
405
|
+
field=field,
|
|
406
|
+
operator=operator,
|
|
407
|
+
value=value,
|
|
408
|
+
action=action,
|
|
409
|
+
case_sensitive=case_sensitive,
|
|
410
|
+
webhook_url_override=clearable_str(webhook_url),
|
|
411
|
+
add_tags=tags,
|
|
412
|
+
preserve_raw=preserve_raw,
|
|
413
|
+
)
|
|
414
|
+
print_success("Rule updated")
|
|
415
|
+
print_rule(rule)
|
|
416
|
+
except APIError as e:
|
|
417
|
+
print_error(e.detail)
|
|
418
|
+
raise typer.Exit(1) from e
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@rule_app.command("delete")
|
|
422
|
+
def delete_rule(
|
|
423
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
424
|
+
rule_id: Annotated[str, typer.Argument(help="Rule ID")],
|
|
425
|
+
force: Annotated[
|
|
426
|
+
bool,
|
|
427
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
428
|
+
] = False,
|
|
429
|
+
profile: Annotated[
|
|
430
|
+
str | None,
|
|
431
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
432
|
+
] = None,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""Delete a rule."""
|
|
435
|
+
if not force:
|
|
436
|
+
confirm = typer.confirm(f"Delete rule {rule_id}?")
|
|
437
|
+
if not confirm:
|
|
438
|
+
raise typer.Exit(0)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
442
|
+
client.delete_rule(domain_id, rule_id)
|
|
443
|
+
print_success(f"Rule {rule_id} deleted")
|
|
444
|
+
except APIError as e:
|
|
445
|
+
print_error(e.detail)
|
|
446
|
+
raise typer.Exit(1) from e
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@rule_app.command("reorder")
|
|
450
|
+
def reorder_rules(
|
|
451
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
452
|
+
ruleset_id: Annotated[str, typer.Argument(help="RuleSet ID")],
|
|
453
|
+
rule_ids: Annotated[
|
|
454
|
+
list[str],
|
|
455
|
+
typer.Argument(help="Every rule ID in the ruleset, in the desired order"),
|
|
456
|
+
],
|
|
457
|
+
profile: Annotated[
|
|
458
|
+
str | None,
|
|
459
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
460
|
+
] = None,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Set the evaluation order of a ruleset's rules."""
|
|
463
|
+
try:
|
|
464
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
465
|
+
client.reorder_rules(domain_id, ruleset_id, rule_ids)
|
|
466
|
+
print_success("Rules reordered")
|
|
467
|
+
except APIError as e:
|
|
468
|
+
print_error(e.detail)
|
|
469
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""User management commands (superuser only).
|
|
2
|
+
|
|
3
|
+
The server's ``UserCreate`` requires only ``username`` and additionally accepts
|
|
4
|
+
``email`` and ``is_superuser``; ``UserUpdate`` accepts ``username``, ``email``,
|
|
5
|
+
``is_active`` and ``is_superuser``. Neither carries a password - accounts
|
|
6
|
+
authenticate with API keys (``fsmtp auth create-key``) - so no command here
|
|
7
|
+
offers one. The only query parameter on ``GET /api/v1/users`` and
|
|
8
|
+
``GET /api/v1/users/{id}`` is ``include_deleted``, exposed as
|
|
9
|
+
``--include-deleted``.
|
|
10
|
+
|
|
11
|
+
``delete`` is a soft delete: the user disappears from every listing and can
|
|
12
|
+
no longer authenticate, but ``restore`` brings the account and its domain
|
|
13
|
+
memberships back. The API keys revoked at deletion are never restored.
|
|
14
|
+
``delete --purge`` removes an already-deleted user for good.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from fastsmtp_cli.client import APIError, FastSMTPClient
|
|
22
|
+
from fastsmtp_cli.commands.options import IncludeDeleted, Purge, clearable_str, confirm_delete
|
|
23
|
+
from fastsmtp_cli.output import (
|
|
24
|
+
print_deleted,
|
|
25
|
+
print_error,
|
|
26
|
+
print_info,
|
|
27
|
+
print_success,
|
|
28
|
+
print_user,
|
|
29
|
+
print_users_table,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(help="User management (superuser only)")
|
|
33
|
+
|
|
34
|
+
ProfileOption = Annotated[
|
|
35
|
+
str | None,
|
|
36
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("list")
|
|
41
|
+
def list_users(include_deleted: IncludeDeleted = False, profile: ProfileOption = None) -> None:
|
|
42
|
+
"""List all users."""
|
|
43
|
+
try:
|
|
44
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
45
|
+
users = client.list_users(include_deleted=include_deleted)
|
|
46
|
+
if not users:
|
|
47
|
+
print_error("No users found")
|
|
48
|
+
return
|
|
49
|
+
print_users_table(users)
|
|
50
|
+
except APIError as e:
|
|
51
|
+
print_error(e.detail)
|
|
52
|
+
raise typer.Exit(1) from e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("get")
|
|
56
|
+
def get_user(
|
|
57
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
58
|
+
include_deleted: IncludeDeleted = False,
|
|
59
|
+
profile: ProfileOption = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Get user details (a deleted user is not found without --include-deleted)."""
|
|
62
|
+
try:
|
|
63
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
64
|
+
user = client.get_user(user_id, include_deleted=include_deleted)
|
|
65
|
+
print_user(user)
|
|
66
|
+
except APIError as e:
|
|
67
|
+
print_error(e.detail)
|
|
68
|
+
raise typer.Exit(1) from e
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("create")
|
|
72
|
+
def create_user(
|
|
73
|
+
username: Annotated[str, typer.Argument(help="Username")],
|
|
74
|
+
email: Annotated[
|
|
75
|
+
str | None,
|
|
76
|
+
typer.Option("--email", "-e", help="Email address"),
|
|
77
|
+
] = None,
|
|
78
|
+
superuser: Annotated[
|
|
79
|
+
bool,
|
|
80
|
+
typer.Option("--superuser", help="Grant superuser privileges"),
|
|
81
|
+
] = False,
|
|
82
|
+
profile: ProfileOption = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Create a new user."""
|
|
85
|
+
try:
|
|
86
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
87
|
+
user = client.create_user(username=username, email=email, is_superuser=superuser)
|
|
88
|
+
print_success(f"User '{username}' created")
|
|
89
|
+
print_user(user)
|
|
90
|
+
except APIError as e:
|
|
91
|
+
print_error(e.detail)
|
|
92
|
+
raise typer.Exit(1) from e
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("update")
|
|
96
|
+
def update_user(
|
|
97
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
98
|
+
username: Annotated[
|
|
99
|
+
str | None,
|
|
100
|
+
typer.Option("--username", "-u", help="New username"),
|
|
101
|
+
] = None,
|
|
102
|
+
email: Annotated[
|
|
103
|
+
str | None,
|
|
104
|
+
typer.Option("--email", "-e", help="New email address; pass '' to clear it"),
|
|
105
|
+
] = None,
|
|
106
|
+
active: Annotated[
|
|
107
|
+
bool | None,
|
|
108
|
+
typer.Option("--active/--inactive", help="Activate or deactivate the account"),
|
|
109
|
+
] = None,
|
|
110
|
+
superuser: Annotated[
|
|
111
|
+
bool | None,
|
|
112
|
+
typer.Option("--superuser/--no-superuser", help="Grant or revoke superuser privileges"),
|
|
113
|
+
] = None,
|
|
114
|
+
profile: ProfileOption = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Update a user."""
|
|
117
|
+
if all(value is None for value in (username, email, active, superuser)):
|
|
118
|
+
print_error("At least one option must be provided")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
123
|
+
user = client.update_user(
|
|
124
|
+
user_id=user_id,
|
|
125
|
+
username=username,
|
|
126
|
+
email=clearable_str(email),
|
|
127
|
+
is_active=active,
|
|
128
|
+
is_superuser=superuser,
|
|
129
|
+
)
|
|
130
|
+
print_success("User updated")
|
|
131
|
+
print_user(user)
|
|
132
|
+
except APIError as e:
|
|
133
|
+
print_error(e.detail)
|
|
134
|
+
raise typer.Exit(1) from e
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("delete")
|
|
138
|
+
def delete_user(
|
|
139
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
140
|
+
force: Annotated[
|
|
141
|
+
bool,
|
|
142
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
143
|
+
] = False,
|
|
144
|
+
purge: Purge = False,
|
|
145
|
+
profile: ProfileOption = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Delete a user (restorable), or permanently purge an already-deleted one."""
|
|
148
|
+
confirm_delete("user", user_id, force=force, purge=purge)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
152
|
+
client.delete_user(user_id, purge=purge)
|
|
153
|
+
print_deleted(
|
|
154
|
+
"user", user_id, purge=purge, restore_command=f"fsmtp users restore {user_id}"
|
|
155
|
+
)
|
|
156
|
+
except APIError as e:
|
|
157
|
+
print_error(e.detail)
|
|
158
|
+
raise typer.Exit(1) from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("restore")
|
|
162
|
+
def restore_user(
|
|
163
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
164
|
+
profile: ProfileOption = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Restore a deleted user and their domain memberships.
|
|
167
|
+
|
|
168
|
+
API keys revoked at deletion stay revoked; create new ones afterwards.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
172
|
+
user = client.restore_user(user_id)
|
|
173
|
+
print_success("User restored")
|
|
174
|
+
print_user(user)
|
|
175
|
+
print_info("API keys revoked at deletion are not restored; create new keys.")
|
|
176
|
+
except APIError as e:
|
|
177
|
+
print_error(e.detail)
|
|
178
|
+
raise typer.Exit(1) from e
|