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/config.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""CLI configuration management with profiles."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# tomllib is stdlib from 3.11 and this package requires >=3.12, so no fallback.
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".fastsmtp"
|
|
15
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
|
|
16
|
+
|
|
17
|
+
# The config file holds API keys in clear, so its mode is set explicitly rather
|
|
18
|
+
# than left to the umask: the common 022 would leave it world-readable. This is
|
|
19
|
+
# what every comparable tool does - ~/.aws/credentials, ~/.docker/config.json,
|
|
20
|
+
# ~/.netrc - and ssh refuses outright to use a key file others can read.
|
|
21
|
+
CONFIG_FILE_MODE = 0o600
|
|
22
|
+
CONFIG_DIR_MODE = 0o700
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Profile(BaseModel):
|
|
26
|
+
"""A server profile configuration."""
|
|
27
|
+
|
|
28
|
+
url: str = Field(default="http://localhost:8000", description="Server URL")
|
|
29
|
+
api_key: str | None = Field(default=None, description="API key for authentication")
|
|
30
|
+
timeout: float = Field(default=30.0, description="Request timeout in seconds")
|
|
31
|
+
verify_ssl: bool = Field(default=True, description="Verify SSL certificates")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CLIConfig(BaseModel):
|
|
35
|
+
"""CLI configuration with multiple profiles."""
|
|
36
|
+
|
|
37
|
+
default_profile: str = Field(default="default", description="Default profile name")
|
|
38
|
+
profiles: dict[str, Profile] = Field(
|
|
39
|
+
default_factory=lambda: {"default": Profile()},
|
|
40
|
+
description="Server profiles",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_config_path() -> Path:
|
|
45
|
+
"""Get the configuration file path."""
|
|
46
|
+
# Check environment variable first
|
|
47
|
+
env_path = os.environ.get("FSMTP_CONFIG")
|
|
48
|
+
if env_path:
|
|
49
|
+
return Path(env_path)
|
|
50
|
+
return DEFAULT_CONFIG_FILE
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config() -> CLIConfig:
|
|
54
|
+
"""Load configuration from file."""
|
|
55
|
+
config_path = get_config_path()
|
|
56
|
+
|
|
57
|
+
if not config_path.exists():
|
|
58
|
+
return CLIConfig()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with open(config_path, "rb") as f:
|
|
62
|
+
data = tomllib.load(f)
|
|
63
|
+
return CLIConfig.model_validate(data)
|
|
64
|
+
except Exception:
|
|
65
|
+
# If config is corrupted, return default
|
|
66
|
+
return CLIConfig()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_config(config: CLIConfig) -> None:
|
|
70
|
+
"""Save configuration to file, readable only by its owner.
|
|
71
|
+
|
|
72
|
+
The mode is applied before the content, not chmod-ed after it, so an API key
|
|
73
|
+
never sits on disk in a file others can read. A file an older version left
|
|
74
|
+
permissive is repaired here, since someone whose key already leaked that way
|
|
75
|
+
should not have to notice and fix it by hand.
|
|
76
|
+
|
|
77
|
+
Only a directory this function *creates* gets ``CONFIG_DIR_MODE``. An existing
|
|
78
|
+
one is left alone deliberately: ``FSMTP_CONFIG`` can point anywhere, and
|
|
79
|
+
tightening, say, ``/etc`` because a config lives under it would be worse than
|
|
80
|
+
the problem being solved.
|
|
81
|
+
"""
|
|
82
|
+
config_path = get_config_path()
|
|
83
|
+
|
|
84
|
+
config_path.parent.mkdir(parents=True, exist_ok=True, mode=CONFIG_DIR_MODE)
|
|
85
|
+
|
|
86
|
+
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, CONFIG_FILE_MODE)
|
|
87
|
+
try:
|
|
88
|
+
# O_CREAT applies the mode only to a file it creates, so an existing one
|
|
89
|
+
# keeps whatever it had. Truncation has already dropped the old content;
|
|
90
|
+
# this lands before the new key does.
|
|
91
|
+
os.fchmod(fd, CONFIG_FILE_MODE)
|
|
92
|
+
handle = os.fdopen(fd, "wb")
|
|
93
|
+
except BaseException:
|
|
94
|
+
os.close(fd)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
# Exclude None values from the dump (TOML can't serialize None)
|
|
98
|
+
with handle as f:
|
|
99
|
+
tomli_w.dump(config.model_dump(exclude_none=True), f)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_profile(profile_name: str | None = None) -> Profile:
|
|
103
|
+
"""Get a profile by name, with environment variable overrides.
|
|
104
|
+
|
|
105
|
+
Priority (highest to lowest):
|
|
106
|
+
1. Environment variables (FSMTP_URL, FSMTP_API_KEY, etc.)
|
|
107
|
+
2. Named profile from config file
|
|
108
|
+
3. Default profile from config file
|
|
109
|
+
4. Built-in defaults
|
|
110
|
+
"""
|
|
111
|
+
config = load_config()
|
|
112
|
+
|
|
113
|
+
# Determine which profile to use
|
|
114
|
+
name = profile_name or os.environ.get("FSMTP_PROFILE") or config.default_profile
|
|
115
|
+
|
|
116
|
+
# Get base profile
|
|
117
|
+
profile = config.profiles[name] if name in config.profiles else Profile()
|
|
118
|
+
|
|
119
|
+
# Apply environment variable overrides
|
|
120
|
+
url = os.environ.get("FSMTP_URL")
|
|
121
|
+
if url:
|
|
122
|
+
profile = profile.model_copy(update={"url": url})
|
|
123
|
+
|
|
124
|
+
api_key = os.environ.get("FSMTP_API_KEY")
|
|
125
|
+
if api_key:
|
|
126
|
+
profile = profile.model_copy(update={"api_key": api_key})
|
|
127
|
+
|
|
128
|
+
timeout = os.environ.get("FSMTP_TIMEOUT")
|
|
129
|
+
if timeout:
|
|
130
|
+
with contextlib.suppress(ValueError):
|
|
131
|
+
profile = profile.model_copy(update={"timeout": float(timeout)})
|
|
132
|
+
|
|
133
|
+
verify_ssl = os.environ.get("FSMTP_VERIFY_SSL")
|
|
134
|
+
if verify_ssl is not None:
|
|
135
|
+
profile = profile.model_copy(
|
|
136
|
+
update={"verify_ssl": verify_ssl.lower() not in ("0", "false", "no")}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return profile
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def set_profile(
|
|
143
|
+
name: str,
|
|
144
|
+
url: str | None = None,
|
|
145
|
+
api_key: str | None = None,
|
|
146
|
+
timeout: float | None = None,
|
|
147
|
+
verify_ssl: bool | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Create or update a profile."""
|
|
150
|
+
config = load_config()
|
|
151
|
+
|
|
152
|
+
# Get existing profile or create new one
|
|
153
|
+
profile_data: dict[str, Any] = (
|
|
154
|
+
config.profiles[name].model_dump() if name in config.profiles else {}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Update with provided values
|
|
158
|
+
if url is not None:
|
|
159
|
+
profile_data["url"] = url
|
|
160
|
+
if api_key is not None:
|
|
161
|
+
profile_data["api_key"] = api_key
|
|
162
|
+
if timeout is not None:
|
|
163
|
+
profile_data["timeout"] = timeout
|
|
164
|
+
if verify_ssl is not None:
|
|
165
|
+
profile_data["verify_ssl"] = verify_ssl
|
|
166
|
+
|
|
167
|
+
config.profiles[name] = Profile.model_validate(profile_data)
|
|
168
|
+
save_config(config)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def delete_profile(name: str) -> bool:
|
|
172
|
+
"""Delete a profile. Returns True if deleted, False if not found."""
|
|
173
|
+
config = load_config()
|
|
174
|
+
|
|
175
|
+
if name not in config.profiles:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
if name == config.default_profile and len(config.profiles) > 1:
|
|
179
|
+
# Can't delete the default profile if there are others
|
|
180
|
+
# Set a different default first
|
|
181
|
+
for other_name in config.profiles:
|
|
182
|
+
if other_name != name:
|
|
183
|
+
config.default_profile = other_name
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
del config.profiles[name]
|
|
187
|
+
|
|
188
|
+
# Ensure at least one profile exists
|
|
189
|
+
if not config.profiles:
|
|
190
|
+
config.profiles["default"] = Profile()
|
|
191
|
+
config.default_profile = "default"
|
|
192
|
+
|
|
193
|
+
save_config(config)
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def set_default_profile(name: str) -> bool:
|
|
198
|
+
"""Set the default profile. Returns True if set, False if profile not found."""
|
|
199
|
+
config = load_config()
|
|
200
|
+
|
|
201
|
+
if name not in config.profiles:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
config.default_profile = name
|
|
205
|
+
save_config(config)
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def list_profiles() -> dict[str, Profile]:
|
|
210
|
+
"""List all profiles."""
|
|
211
|
+
config = load_config()
|
|
212
|
+
return config.profiles
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_default_profile_name() -> str:
|
|
216
|
+
"""Get the name of the default profile."""
|
|
217
|
+
config = load_config()
|
|
218
|
+
return config.default_profile
|
fastsmtp_cli/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""FastSMTP CLI - Remote CLI client for FastSMTP server."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from fastsmtp_cli import __version__
|
|
8
|
+
from fastsmtp_cli.commands import auth, config, domains, operations, recipients, rules, users
|
|
9
|
+
from fastsmtp_cli.output import console
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="fsmtp",
|
|
13
|
+
help="FastSMTP CLI - Remote CLI client for FastSMTP server",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Global options
|
|
18
|
+
ProfileOption = Annotated[
|
|
19
|
+
str | None,
|
|
20
|
+
typer.Option("--profile", "-p", help="Profile to use", envvar="FSMTP_PROFILE"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version_callback(value: bool) -> None:
|
|
25
|
+
"""Print version and exit."""
|
|
26
|
+
if value:
|
|
27
|
+
console.print(f"FastSMTP CLI version {__version__}")
|
|
28
|
+
raise typer.Exit()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def main(
|
|
33
|
+
version: Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--version",
|
|
37
|
+
"-V",
|
|
38
|
+
callback=version_callback,
|
|
39
|
+
is_eager=True,
|
|
40
|
+
help="Show version and exit",
|
|
41
|
+
),
|
|
42
|
+
] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""FastSMTP CLI - Remote CLI client for FastSMTP server."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Register command groups
|
|
49
|
+
app.add_typer(config.app, name="config", help="Manage CLI configuration and profiles")
|
|
50
|
+
app.add_typer(auth.app, name="auth", help="Authentication and API key management")
|
|
51
|
+
app.add_typer(users.app, name="users", help="User management (superuser only)")
|
|
52
|
+
app.add_typer(domains.app, name="domain", help="Domain management")
|
|
53
|
+
app.add_typer(recipients.app, name="recipient", help="Recipient management")
|
|
54
|
+
app.add_typer(rules.app, name="rules", help="RuleSet and rule management")
|
|
55
|
+
app.add_typer(operations.app, name="ops", help="Operations (health, logs, test)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
app()
|