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/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()