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,130 @@
|
|
|
1
|
+
"""Authentication and API key management commands."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from fastsmtp_cli.client import APIError, FastSMTPClient
|
|
9
|
+
from fastsmtp_cli.commands.options import IncludeDeletedKeys
|
|
10
|
+
from fastsmtp_cli.output import (
|
|
11
|
+
print_api_key,
|
|
12
|
+
print_api_keys_table,
|
|
13
|
+
print_error,
|
|
14
|
+
print_success,
|
|
15
|
+
print_whoami,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Authentication and API key management")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("whoami")
|
|
22
|
+
def whoami(
|
|
23
|
+
profile: Annotated[
|
|
24
|
+
str | None,
|
|
25
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
26
|
+
] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Show current authenticated user info."""
|
|
29
|
+
try:
|
|
30
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
31
|
+
result = client.whoami()
|
|
32
|
+
print_whoami(result)
|
|
33
|
+
except APIError as e:
|
|
34
|
+
print_error(e.detail)
|
|
35
|
+
raise typer.Exit(1) from e
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("keys")
|
|
39
|
+
def list_keys(
|
|
40
|
+
include_deleted: IncludeDeletedKeys = False,
|
|
41
|
+
profile: Annotated[
|
|
42
|
+
str | None,
|
|
43
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
44
|
+
] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""List your API keys (--include-deleted also shows deleted and retired ones)."""
|
|
47
|
+
try:
|
|
48
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
49
|
+
keys = client.list_api_keys(include_deleted=include_deleted)
|
|
50
|
+
if not keys:
|
|
51
|
+
print_error("No API keys found")
|
|
52
|
+
return
|
|
53
|
+
print_api_keys_table(keys)
|
|
54
|
+
except APIError as e:
|
|
55
|
+
print_error(e.detail)
|
|
56
|
+
raise typer.Exit(1) from e
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("create-key")
|
|
60
|
+
def create_key(
|
|
61
|
+
name: Annotated[str, typer.Argument(help="Key name")],
|
|
62
|
+
scopes: Annotated[
|
|
63
|
+
list[str] | None,
|
|
64
|
+
typer.Option("--scope", "-s", help="Scopes for the key (can be repeated)"),
|
|
65
|
+
] = None,
|
|
66
|
+
expires: Annotated[
|
|
67
|
+
int | None,
|
|
68
|
+
typer.Option("--expires", "-e", help="Days until expiration"),
|
|
69
|
+
] = None,
|
|
70
|
+
profile: Annotated[
|
|
71
|
+
str | None,
|
|
72
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
73
|
+
] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Create a new API key."""
|
|
76
|
+
# The server takes an absolute expiry timestamp; --expires is in days.
|
|
77
|
+
expires_at = datetime.now(UTC) + timedelta(days=expires) if expires else None
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
81
|
+
result = client.create_api_key(name=name, scopes=scopes, expires_at=expires_at)
|
|
82
|
+
print_api_key(result, show_secret=True)
|
|
83
|
+
except APIError as e:
|
|
84
|
+
print_error(e.detail)
|
|
85
|
+
raise typer.Exit(1) from e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("delete-key")
|
|
89
|
+
def delete_key(
|
|
90
|
+
key_id: Annotated[str, typer.Argument(help="Key ID to delete")],
|
|
91
|
+
force: Annotated[
|
|
92
|
+
bool,
|
|
93
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
94
|
+
] = False,
|
|
95
|
+
profile: Annotated[
|
|
96
|
+
str | None,
|
|
97
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
98
|
+
] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Delete an API key. Deleted keys cannot be restored; create or rotate instead."""
|
|
101
|
+
if not force:
|
|
102
|
+
confirm = typer.confirm(f"Delete API key {key_id}?")
|
|
103
|
+
if not confirm:
|
|
104
|
+
raise typer.Exit(0)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
108
|
+
client.delete_api_key(key_id)
|
|
109
|
+
print_success(f"API key {key_id} deleted")
|
|
110
|
+
except APIError as e:
|
|
111
|
+
print_error(e.detail)
|
|
112
|
+
raise typer.Exit(1) from e
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("rotate-key")
|
|
116
|
+
def rotate_key(
|
|
117
|
+
key_id: Annotated[str, typer.Argument(help="Key ID to rotate")],
|
|
118
|
+
profile: Annotated[
|
|
119
|
+
str | None,
|
|
120
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
121
|
+
] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Rotate an API key (generates new secret)."""
|
|
124
|
+
try:
|
|
125
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
126
|
+
result = client.rotate_api_key(key_id)
|
|
127
|
+
print_api_key(result, show_secret=True)
|
|
128
|
+
except APIError as e:
|
|
129
|
+
print_error(e.detail)
|
|
130
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""CLI configuration commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from fastsmtp_cli import config as cfg
|
|
8
|
+
from fastsmtp_cli.output import (
|
|
9
|
+
console,
|
|
10
|
+
print_error,
|
|
11
|
+
print_profiles_table,
|
|
12
|
+
print_success,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Manage CLI configuration and profiles")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("show")
|
|
19
|
+
def show_config(
|
|
20
|
+
show_keys: Annotated[
|
|
21
|
+
bool,
|
|
22
|
+
typer.Option("--show-keys", "-k", help="Show API keys in output"),
|
|
23
|
+
] = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show current configuration."""
|
|
26
|
+
profiles = cfg.list_profiles()
|
|
27
|
+
default = cfg.get_default_profile_name()
|
|
28
|
+
|
|
29
|
+
if not profiles:
|
|
30
|
+
console.print("[dim]No profiles configured[/dim]")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
print_profiles_table(profiles, default, show_keys=show_keys)
|
|
34
|
+
console.print(f"\n[dim]Config file: {cfg.get_config_path()}[/dim]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("set")
|
|
38
|
+
def set_profile(
|
|
39
|
+
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
40
|
+
url: Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option("--url", "-u", help="Server URL"),
|
|
43
|
+
] = None,
|
|
44
|
+
api_key: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option("--api-key", "-k", help="API key"),
|
|
47
|
+
] = None,
|
|
48
|
+
timeout: Annotated[
|
|
49
|
+
float | None,
|
|
50
|
+
typer.Option("--timeout", "-t", help="Request timeout in seconds"),
|
|
51
|
+
] = None,
|
|
52
|
+
verify_ssl: Annotated[
|
|
53
|
+
bool | None,
|
|
54
|
+
typer.Option("--verify-ssl/--no-verify-ssl", help="Verify SSL certificates"),
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Create or update a profile."""
|
|
58
|
+
if url is None and api_key is None and timeout is None and verify_ssl is None:
|
|
59
|
+
print_error("At least one option must be provided")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
cfg.set_profile(
|
|
63
|
+
name=name,
|
|
64
|
+
url=url,
|
|
65
|
+
api_key=api_key,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
verify_ssl=verify_ssl,
|
|
68
|
+
)
|
|
69
|
+
print_success(f"Profile '{name}' updated")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("delete")
|
|
73
|
+
def delete_profile(
|
|
74
|
+
name: Annotated[str, typer.Argument(help="Profile name to delete")],
|
|
75
|
+
force: Annotated[
|
|
76
|
+
bool,
|
|
77
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
78
|
+
] = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Delete a profile."""
|
|
81
|
+
if not force:
|
|
82
|
+
confirm = typer.confirm(f"Delete profile '{name}'?")
|
|
83
|
+
if not confirm:
|
|
84
|
+
raise typer.Exit(0)
|
|
85
|
+
|
|
86
|
+
if cfg.delete_profile(name):
|
|
87
|
+
print_success(f"Profile '{name}' deleted")
|
|
88
|
+
else:
|
|
89
|
+
print_error(f"Profile '{name}' not found")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("use")
|
|
94
|
+
def use_profile(
|
|
95
|
+
name: Annotated[str, typer.Argument(help="Profile name to set as default")],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Set the default profile."""
|
|
98
|
+
if cfg.set_default_profile(name):
|
|
99
|
+
print_success(f"Default profile set to '{name}'")
|
|
100
|
+
else:
|
|
101
|
+
print_error(f"Profile '{name}' not found")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("init")
|
|
106
|
+
def init_config(
|
|
107
|
+
url: Annotated[
|
|
108
|
+
str,
|
|
109
|
+
typer.Option("--url", "-u", help="Server URL", prompt=True),
|
|
110
|
+
] = "http://localhost:8000",
|
|
111
|
+
api_key: Annotated[
|
|
112
|
+
str | None,
|
|
113
|
+
typer.Option("--api-key", "-k", help="API key"),
|
|
114
|
+
] = None,
|
|
115
|
+
profile: Annotated[
|
|
116
|
+
str,
|
|
117
|
+
typer.Option("--profile", "-p", help="Profile name"),
|
|
118
|
+
] = "default",
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Initialize CLI configuration."""
|
|
121
|
+
config_path = cfg.get_config_path()
|
|
122
|
+
|
|
123
|
+
if config_path.exists() and not typer.confirm("Configuration already exists. Overwrite?"):
|
|
124
|
+
raise typer.Exit(0)
|
|
125
|
+
|
|
126
|
+
cfg.set_profile(name=profile, url=url, api_key=api_key)
|
|
127
|
+
cfg.set_default_profile(profile)
|
|
128
|
+
|
|
129
|
+
print_success(f"Configuration saved to {config_path}")
|
|
130
|
+
console.print(f"\nProfile '{profile}' created with URL: {url}")
|
|
131
|
+
|
|
132
|
+
if not api_key:
|
|
133
|
+
console.print(
|
|
134
|
+
"\n[yellow]Note:[/yellow] No API key configured. "
|
|
135
|
+
"Set one with: fsmtp config set default --api-key YOUR_KEY"
|
|
136
|
+
)
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Domain and member management commands.
|
|
2
|
+
|
|
3
|
+
``delete`` is a soft delete: the domain stops receiving mail, its recipients
|
|
4
|
+
are deleted with it and their queued deliveries are cancelled, but
|
|
5
|
+
``restore`` brings everything back (recipients deleted independently earlier
|
|
6
|
+
stay deleted). ``delete --purge`` removes an already-deleted domain for good,
|
|
7
|
+
together with its recipients, rulesets and members.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from fastsmtp_cli.client import UNSET, APIError, FastSMTPClient
|
|
16
|
+
from fastsmtp_cli.commands.options import IncludeDeleted, Purge, confirm_delete
|
|
17
|
+
from fastsmtp_cli.output import (
|
|
18
|
+
print_deleted,
|
|
19
|
+
print_domain,
|
|
20
|
+
print_domains_table,
|
|
21
|
+
print_error,
|
|
22
|
+
print_members_table,
|
|
23
|
+
print_success,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="Domain management")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TriState(str, Enum):
|
|
30
|
+
"""A per-domain override of a server-wide boolean setting.
|
|
31
|
+
|
|
32
|
+
``inherit`` clears the override so the domain follows the server default;
|
|
33
|
+
leaving the option off entirely means "don't touch this setting".
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
true = "true"
|
|
37
|
+
false = "false"
|
|
38
|
+
inherit = "inherit"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tri_state_value(option: TriState | None) -> bool | None:
|
|
42
|
+
"""Map a tri-state option to the boolean (or null) the API expects."""
|
|
43
|
+
if option is TriState.true:
|
|
44
|
+
return True
|
|
45
|
+
if option is TriState.false:
|
|
46
|
+
return False
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create_flag(option: TriState | None) -> bool | None:
|
|
51
|
+
"""Flag value for a create call: ``inherit`` and "unset" both mean omit."""
|
|
52
|
+
if option is None or option is TriState.inherit:
|
|
53
|
+
return None
|
|
54
|
+
return _tri_state_value(option)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _update_flag(option: TriState | None) -> Any:
|
|
58
|
+
"""Flag value for an update call, keeping "unset" distinct from ``inherit``."""
|
|
59
|
+
if option is None:
|
|
60
|
+
return UNSET
|
|
61
|
+
return _tri_state_value(option)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
VerifyDkim = Annotated[
|
|
65
|
+
TriState | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--verify-dkim", help="Verify DKIM signatures (true/false/inherit, superuser only)"
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
VerifySpf = Annotated[
|
|
71
|
+
TriState | None,
|
|
72
|
+
typer.Option("--verify-spf", help="Verify SPF records (true/false/inherit, superuser only)"),
|
|
73
|
+
]
|
|
74
|
+
RejectDkimFail = Annotated[
|
|
75
|
+
TriState | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--reject-dkim-fail", help="Reject on DKIM failure (true/false/inherit, superuser only)"
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
RejectSpfFail = Annotated[
|
|
81
|
+
TriState | None,
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--reject-spf-fail", help="Reject on SPF failure (true/false/inherit, superuser only)"
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
PreserveRawMessage = Annotated[
|
|
87
|
+
TriState | None,
|
|
88
|
+
typer.Option(
|
|
89
|
+
"--preserve-raw-message",
|
|
90
|
+
help="Preserve the raw MIME message in S3 (true/false/inherit)",
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("list")
|
|
96
|
+
def list_domains(
|
|
97
|
+
include_deleted: IncludeDeleted = False,
|
|
98
|
+
profile: Annotated[
|
|
99
|
+
str | None,
|
|
100
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
101
|
+
] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""List domains you have access to (deleted ones only where you are owner)."""
|
|
104
|
+
try:
|
|
105
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
106
|
+
domains = client.list_domains(include_deleted=include_deleted)
|
|
107
|
+
if not domains:
|
|
108
|
+
print_error("No domains found")
|
|
109
|
+
return
|
|
110
|
+
print_domains_table(domains)
|
|
111
|
+
except APIError as e:
|
|
112
|
+
print_error(e.detail)
|
|
113
|
+
raise typer.Exit(1) from e
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("get")
|
|
117
|
+
def get_domain(
|
|
118
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
119
|
+
include_deleted: IncludeDeleted = False,
|
|
120
|
+
profile: Annotated[
|
|
121
|
+
str | None,
|
|
122
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
123
|
+
] = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Get domain details (a deleted domain is not found without --include-deleted)."""
|
|
126
|
+
try:
|
|
127
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
128
|
+
domain = client.get_domain(domain_id, include_deleted=include_deleted)
|
|
129
|
+
print_domain(domain)
|
|
130
|
+
except APIError as e:
|
|
131
|
+
print_error(e.detail)
|
|
132
|
+
raise typer.Exit(1) from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command("create")
|
|
136
|
+
def create_domain(
|
|
137
|
+
domain_name: Annotated[str, typer.Argument(help="Domain name (e.g., example.com)")],
|
|
138
|
+
verify_dkim: VerifyDkim = None,
|
|
139
|
+
verify_spf: VerifySpf = None,
|
|
140
|
+
reject_dkim_fail: RejectDkimFail = None,
|
|
141
|
+
reject_spf_fail: RejectSpfFail = None,
|
|
142
|
+
preserve_raw_message: PreserveRawMessage = None,
|
|
143
|
+
profile: Annotated[
|
|
144
|
+
str | None,
|
|
145
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
146
|
+
] = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Create a new domain."""
|
|
149
|
+
try:
|
|
150
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
151
|
+
domain = client.create_domain(
|
|
152
|
+
domain_name=domain_name,
|
|
153
|
+
verify_dkim=_create_flag(verify_dkim),
|
|
154
|
+
verify_spf=_create_flag(verify_spf),
|
|
155
|
+
reject_dkim_fail=_create_flag(reject_dkim_fail),
|
|
156
|
+
reject_spf_fail=_create_flag(reject_spf_fail),
|
|
157
|
+
preserve_raw_message=_create_flag(preserve_raw_message),
|
|
158
|
+
)
|
|
159
|
+
print_success(f"Domain '{domain_name}' created")
|
|
160
|
+
print_domain(domain)
|
|
161
|
+
except APIError as e:
|
|
162
|
+
print_error(e.detail)
|
|
163
|
+
raise typer.Exit(1) from e
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("update")
|
|
167
|
+
def update_domain(
|
|
168
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
169
|
+
enabled: Annotated[
|
|
170
|
+
bool | None,
|
|
171
|
+
typer.Option("--enabled/--disabled", help="Enable or disable domain"),
|
|
172
|
+
] = None,
|
|
173
|
+
verify_dkim: VerifyDkim = None,
|
|
174
|
+
verify_spf: VerifySpf = None,
|
|
175
|
+
reject_dkim_fail: RejectDkimFail = None,
|
|
176
|
+
reject_spf_fail: RejectSpfFail = None,
|
|
177
|
+
preserve_raw_message: PreserveRawMessage = None,
|
|
178
|
+
profile: Annotated[
|
|
179
|
+
str | None,
|
|
180
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
181
|
+
] = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Update a domain."""
|
|
184
|
+
options = [
|
|
185
|
+
enabled,
|
|
186
|
+
verify_dkim,
|
|
187
|
+
verify_spf,
|
|
188
|
+
reject_dkim_fail,
|
|
189
|
+
reject_spf_fail,
|
|
190
|
+
preserve_raw_message,
|
|
191
|
+
]
|
|
192
|
+
if all(value is None for value in options):
|
|
193
|
+
print_error("At least one option must be provided")
|
|
194
|
+
raise typer.Exit(1)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
198
|
+
domain = client.update_domain(
|
|
199
|
+
domain_id=domain_id,
|
|
200
|
+
is_enabled=enabled,
|
|
201
|
+
verify_dkim=_update_flag(verify_dkim),
|
|
202
|
+
verify_spf=_update_flag(verify_spf),
|
|
203
|
+
reject_dkim_fail=_update_flag(reject_dkim_fail),
|
|
204
|
+
reject_spf_fail=_update_flag(reject_spf_fail),
|
|
205
|
+
preserve_raw_message=_update_flag(preserve_raw_message),
|
|
206
|
+
)
|
|
207
|
+
print_success("Domain updated")
|
|
208
|
+
print_domain(domain)
|
|
209
|
+
except APIError as e:
|
|
210
|
+
print_error(e.detail)
|
|
211
|
+
raise typer.Exit(1) from e
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command("delete")
|
|
215
|
+
def delete_domain(
|
|
216
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
217
|
+
force: Annotated[
|
|
218
|
+
bool,
|
|
219
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
220
|
+
] = False,
|
|
221
|
+
purge: Purge = False,
|
|
222
|
+
profile: Annotated[
|
|
223
|
+
str | None,
|
|
224
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
225
|
+
] = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Delete a domain (restorable), or permanently purge an already-deleted one."""
|
|
228
|
+
confirm_delete("domain", domain_id, force=force, purge=purge)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
232
|
+
client.delete_domain(domain_id, purge=purge)
|
|
233
|
+
print_deleted(
|
|
234
|
+
"domain",
|
|
235
|
+
domain_id,
|
|
236
|
+
purge=purge,
|
|
237
|
+
restore_command=f"fsmtp domain restore {domain_id}",
|
|
238
|
+
)
|
|
239
|
+
except APIError as e:
|
|
240
|
+
print_error(e.detail)
|
|
241
|
+
raise typer.Exit(1) from e
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command("restore")
|
|
245
|
+
def restore_domain(
|
|
246
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
247
|
+
profile: Annotated[
|
|
248
|
+
str | None,
|
|
249
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
250
|
+
] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Restore a deleted domain and the recipients deleted with it."""
|
|
253
|
+
try:
|
|
254
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
255
|
+
domain = client.restore_domain(domain_id)
|
|
256
|
+
print_success("Domain restored")
|
|
257
|
+
print_domain(domain)
|
|
258
|
+
except APIError as e:
|
|
259
|
+
print_error(e.detail)
|
|
260
|
+
raise typer.Exit(1) from e
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# Member subcommands
|
|
264
|
+
member_app = typer.Typer(help="Domain member management")
|
|
265
|
+
app.add_typer(member_app, name="member")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@member_app.command("list")
|
|
269
|
+
def list_members(
|
|
270
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
271
|
+
profile: Annotated[
|
|
272
|
+
str | None,
|
|
273
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
274
|
+
] = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""List domain members."""
|
|
277
|
+
try:
|
|
278
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
279
|
+
members = client.list_members(domain_id)
|
|
280
|
+
if not members:
|
|
281
|
+
print_error("No members found")
|
|
282
|
+
return
|
|
283
|
+
print_members_table(members)
|
|
284
|
+
except APIError as e:
|
|
285
|
+
print_error(e.detail)
|
|
286
|
+
raise typer.Exit(1) from e
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@member_app.command("add")
|
|
290
|
+
def add_member(
|
|
291
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
292
|
+
user_id: Annotated[str, typer.Argument(help="User ID to add")],
|
|
293
|
+
role: Annotated[
|
|
294
|
+
str,
|
|
295
|
+
typer.Option("--role", "-r", help="Role (owner, admin, member)"),
|
|
296
|
+
] = "member",
|
|
297
|
+
profile: Annotated[
|
|
298
|
+
str | None,
|
|
299
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
300
|
+
] = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Add a member to a domain."""
|
|
303
|
+
if role not in ("owner", "admin", "member"):
|
|
304
|
+
print_error("Role must be one of: owner, admin, member")
|
|
305
|
+
raise typer.Exit(1)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
309
|
+
client.add_member(domain_id, user_id, role=role)
|
|
310
|
+
print_success(f"User {user_id} added to domain as {role}")
|
|
311
|
+
except APIError as e:
|
|
312
|
+
print_error(e.detail)
|
|
313
|
+
raise typer.Exit(1) from e
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@member_app.command("update")
|
|
317
|
+
def update_member(
|
|
318
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
319
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
320
|
+
role: Annotated[str, typer.Option("--role", "-r", help="New role", prompt=True)],
|
|
321
|
+
profile: Annotated[
|
|
322
|
+
str | None,
|
|
323
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
324
|
+
] = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Update a member's role."""
|
|
327
|
+
if role not in ("owner", "admin", "member"):
|
|
328
|
+
print_error("Role must be one of: owner, admin, member")
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
333
|
+
client.update_member(domain_id, user_id, role=role)
|
|
334
|
+
print_success(f"Member role updated to {role}")
|
|
335
|
+
except APIError as e:
|
|
336
|
+
print_error(e.detail)
|
|
337
|
+
raise typer.Exit(1) from e
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@member_app.command("remove")
|
|
341
|
+
def remove_member(
|
|
342
|
+
domain_id: Annotated[str, typer.Argument(help="Domain ID")],
|
|
343
|
+
user_id: Annotated[str, typer.Argument(help="User ID to remove")],
|
|
344
|
+
force: Annotated[
|
|
345
|
+
bool,
|
|
346
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
347
|
+
] = False,
|
|
348
|
+
profile: Annotated[
|
|
349
|
+
str | None,
|
|
350
|
+
typer.Option("--profile", "-p", help="Profile to use"),
|
|
351
|
+
] = None,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Remove a member from a domain."""
|
|
354
|
+
if not force:
|
|
355
|
+
confirm = typer.confirm(f"Remove user {user_id} from domain?")
|
|
356
|
+
if not confirm:
|
|
357
|
+
raise typer.Exit(0)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
with FastSMTPClient(profile_name=profile) as client:
|
|
361
|
+
client.remove_member(domain_id, user_id)
|
|
362
|
+
print_success(f"User {user_id} removed from domain")
|
|
363
|
+
except APIError as e:
|
|
364
|
+
print_error(e.detail)
|
|
365
|
+
raise typer.Exit(1) from e
|