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.
@@ -0,0 +1,174 @@
1
+ """Operations commands (health, logs, test webhook)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fastsmtp_cli.client import APIError, FastSMTPClient
8
+ from fastsmtp_cli.commands.options import IncludeDeletedLogs
9
+ from fastsmtp_cli.output import (
10
+ print_delivery_log,
11
+ print_delivery_logs_table,
12
+ print_error,
13
+ print_health,
14
+ print_ready,
15
+ print_success,
16
+ print_test_webhook_result,
17
+ )
18
+
19
+ app = typer.Typer(help="Operations (health, logs, test)")
20
+
21
+
22
+ @app.command("health")
23
+ def health(
24
+ profile: Annotated[
25
+ str | None,
26
+ typer.Option("--profile", "-p", help="Profile to use"),
27
+ ] = None,
28
+ ) -> None:
29
+ """Check server health."""
30
+ try:
31
+ with FastSMTPClient(profile_name=profile) as client:
32
+ result = client.health()
33
+ print_health(result)
34
+ except APIError as e:
35
+ print_error(e.detail)
36
+ raise typer.Exit(1) from e
37
+
38
+
39
+ @app.command("ready")
40
+ def ready(
41
+ profile: Annotated[
42
+ str | None,
43
+ typer.Option("--profile", "-p", help="Profile to use"),
44
+ ] = None,
45
+ ) -> None:
46
+ """Check server readiness (includes database check)."""
47
+ try:
48
+ with FastSMTPClient(profile_name=profile) as client:
49
+ result = client.ready()
50
+ print_ready(result)
51
+ except APIError as e:
52
+ print_error(e.detail)
53
+ raise typer.Exit(1) from e
54
+
55
+
56
+ # Delivery log commands
57
+ log_app = typer.Typer(help="Delivery log management")
58
+ app.add_typer(log_app, name="log")
59
+
60
+
61
+ @log_app.command("list")
62
+ def list_logs(
63
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
64
+ status: Annotated[
65
+ str | None,
66
+ typer.Option("--status", "-s", help="Filter by status"),
67
+ ] = None,
68
+ message_id: Annotated[
69
+ str | None,
70
+ typer.Option("--message-id", "-m", help="Filter by message ID"),
71
+ ] = None,
72
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Max results")] = 50,
73
+ offset: Annotated[int, typer.Option("--offset", "-o", help="Offset")] = 0,
74
+ include_deleted: IncludeDeletedLogs = False,
75
+ profile: Annotated[
76
+ str | None,
77
+ typer.Option("--profile", "-p", help="Profile to use"),
78
+ ] = None,
79
+ ) -> None:
80
+ """List delivery logs for a domain (--include-deleted reads a deleted domain's history)."""
81
+ try:
82
+ with FastSMTPClient(profile_name=profile) as client:
83
+ logs = client.list_delivery_logs(
84
+ domain_id,
85
+ status=status,
86
+ message_id=message_id,
87
+ limit=limit,
88
+ offset=offset,
89
+ include_deleted=include_deleted,
90
+ )
91
+ if not logs:
92
+ print_error("No delivery logs found")
93
+ return
94
+ print_delivery_logs_table(logs)
95
+ except APIError as e:
96
+ print_error(e.detail)
97
+ raise typer.Exit(1) from e
98
+
99
+
100
+ @log_app.command("get")
101
+ def get_log(
102
+ log_id: Annotated[str, typer.Argument(help="Delivery log ID")],
103
+ profile: Annotated[
104
+ str | None,
105
+ typer.Option("--profile", "-p", help="Profile to use"),
106
+ ] = None,
107
+ ) -> None:
108
+ """Get delivery log details with payload."""
109
+ try:
110
+ with FastSMTPClient(profile_name=profile) as client:
111
+ log = client.get_delivery_log(log_id)
112
+ print_delivery_log(log)
113
+ except APIError as e:
114
+ print_error(e.detail)
115
+ raise typer.Exit(1) from e
116
+
117
+
118
+ @log_app.command("retry")
119
+ def retry_log(
120
+ log_id: Annotated[str, typer.Argument(help="Delivery log ID")],
121
+ profile: Annotated[
122
+ str | None,
123
+ typer.Option("--profile", "-p", help="Profile to use"),
124
+ ] = None,
125
+ ) -> None:
126
+ """Retry a failed, exhausted or cancelled delivery."""
127
+ try:
128
+ with FastSMTPClient(profile_name=profile) as client:
129
+ result = client.retry_delivery(log_id)
130
+ print_success(result.get("message", "Delivery queued for retry"))
131
+ except APIError as e:
132
+ print_error(e.detail)
133
+ raise typer.Exit(1) from e
134
+
135
+
136
+ # Test webhook command
137
+ @app.command("test-webhook")
138
+ def test_webhook(
139
+ webhook_url: Annotated[str, typer.Argument(help="Webhook URL to test")],
140
+ from_addr: Annotated[
141
+ str,
142
+ typer.Option("--from", "-f", help="From address"),
143
+ ] = "test@example.com",
144
+ to_addr: Annotated[
145
+ str,
146
+ typer.Option("--to", "-t", help="To address"),
147
+ ] = "recipient@example.com",
148
+ subject: Annotated[
149
+ str,
150
+ typer.Option("--subject", "-s", help="Subject"),
151
+ ] = "Test Email",
152
+ body: Annotated[
153
+ str,
154
+ typer.Option("--body", "-b", help="Body text"),
155
+ ] = "This is a test email from FastSMTP.",
156
+ profile: Annotated[
157
+ str | None,
158
+ typer.Option("--profile", "-p", help="Profile to use"),
159
+ ] = None,
160
+ ) -> None:
161
+ """Test a webhook URL by sending a test payload."""
162
+ try:
163
+ with FastSMTPClient(profile_name=profile) as client:
164
+ result = client.test_webhook(
165
+ webhook_url=webhook_url,
166
+ from_address=from_addr,
167
+ to_address=to_addr,
168
+ subject=subject,
169
+ body=body,
170
+ )
171
+ print_test_webhook_result(result)
172
+ except APIError as e:
173
+ print_error(e.detail)
174
+ raise typer.Exit(1) from e
@@ -0,0 +1,84 @@
1
+ """Shared option aliases and helpers for the command modules."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from fastsmtp_cli.client import UNSET, NullableStr
8
+
9
+ #: ``--include-deleted`` on every list/get that can show tombstones. The server
10
+ #: gates the flag at the delete role for that resource: below it a caller with
11
+ #: no access at all gets the same 403 whether or not a tombstone exists, and a
12
+ #: member of the domain gets a 404 once it is deleted.
13
+ IncludeDeleted = Annotated[
14
+ bool,
15
+ typer.Option("--include-deleted", help="Also show deleted (restorable) entries"),
16
+ ]
17
+
18
+ #: The same flag on ``auth keys``: keys are never restorable, and the listing
19
+ #: also covers keys retired before soft delete existed.
20
+ IncludeDeletedKeys = Annotated[
21
+ bool,
22
+ typer.Option(
23
+ "--include-deleted", help="Also show deleted and retired keys (keys cannot be restored)"
24
+ ),
25
+ ]
26
+
27
+ #: The same flag on ``ops log list``: log rows are never deleted, so the flag
28
+ #: only resolves a deleted domain (owner or superuser) to read its history.
29
+ IncludeDeletedLogs = Annotated[
30
+ bool,
31
+ typer.Option(
32
+ "--include-deleted",
33
+ help="Read the history of a deleted domain (owner or superuser); log rows themselves "
34
+ "are never deleted",
35
+ ),
36
+ ]
37
+
38
+ #: ``--purge`` on every delete. A purge is a second step: it only works on a row
39
+ #: that is already deleted (the server answers 409 otherwise), and it runs the
40
+ #: hard-delete cascades that soft delete exists to avoid.
41
+ Purge = Annotated[
42
+ bool,
43
+ typer.Option(
44
+ "--purge",
45
+ help="Permanently delete an already-deleted entry (superuser only, cannot be undone)",
46
+ ),
47
+ ]
48
+
49
+
50
+ def confirm_delete(kind: str, label: str, *, force: bool, purge: bool) -> None:
51
+ """Ask before a delete unless ``--force``; exit 0 when the operator declines.
52
+
53
+ Shared by the user, domain and recipient ``delete`` commands. A soft
54
+ delete keeps the pre-0.5.0 prompt; a purge gets its own, spelling out that
55
+ it cannot be undone.
56
+ """
57
+ if force:
58
+ return
59
+ if purge:
60
+ prompt = f"Permanently delete {kind} {label}? This cannot be undone."
61
+ else:
62
+ prompt = f"Delete {kind} {label}?"
63
+ if not typer.confirm(prompt):
64
+ raise typer.Exit(0)
65
+
66
+
67
+ def clearable_str(option: str | None) -> NullableStr:
68
+ """Map a nullable-string option onto the client's tri-state value.
69
+
70
+ CLI-wide convention for options backed by a nullable string column: leaving
71
+ the option off (``None``) leaves the column untouched, passing an empty
72
+ string (``--option ''``) clears it (the client sends an explicit JSON
73
+ null), and any other value sets it.
74
+
75
+ This is the string counterpart of the ``true``/``false``/``inherit``
76
+ tri-state used for nullable boolean flags (see ``commands/domains.py``);
77
+ strings need no enum because the empty string is not an otherwise
78
+ meaningful value for these columns.
79
+ """
80
+ if option is None:
81
+ return UNSET
82
+ if option == "":
83
+ return None
84
+ return option
@@ -0,0 +1,223 @@
1
+ """Recipient management commands.
2
+
3
+ Recipients are nested under a domain on the server, so every command that
4
+ addresses a single recipient takes the domain ID as well as the recipient ID.
5
+
6
+ ``delete`` is a soft delete: the address stops receiving mail and its queued
7
+ deliveries are cancelled, but ``restore`` brings it back (cancelled deliveries
8
+ stay cancelled; re-queue them with ``fsmtp ops log retry``). ``delete
9
+ --purge`` removes an already-deleted recipient for good.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from fastsmtp_cli.client import APIError, FastSMTPClient
17
+ from fastsmtp_cli.commands.options import IncludeDeleted, Purge, clearable_str, confirm_delete
18
+ from fastsmtp_cli.output import (
19
+ print_deleted,
20
+ print_error,
21
+ print_recipient,
22
+ print_recipients_table,
23
+ print_success,
24
+ )
25
+
26
+ app = typer.Typer(help="Recipient management")
27
+
28
+ HeaderOption = Annotated[
29
+ list[str] | None,
30
+ typer.Option("--header", "-H", help="Webhook header as KEY=VALUE (can be repeated)"),
31
+ ]
32
+
33
+
34
+ def parse_headers(headers: list[str] | None) -> dict[str, str] | None:
35
+ """Parse repeated ``KEY=VALUE`` options into a headers mapping.
36
+
37
+ Raises:
38
+ typer.Exit: with status 1 if an entry is not ``KEY=VALUE``
39
+ """
40
+ if not headers:
41
+ return None
42
+ parsed: dict[str, str] = {}
43
+ for entry in headers:
44
+ key, separator, value = entry.partition("=")
45
+ if not separator or not key.strip():
46
+ print_error(f"Invalid header '{entry}'. Use KEY=VALUE.")
47
+ raise typer.Exit(1)
48
+ parsed[key.strip()] = value.strip()
49
+ return parsed
50
+
51
+
52
+ @app.command("list")
53
+ def list_recipients(
54
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
55
+ include_deleted: IncludeDeleted = False,
56
+ profile: Annotated[
57
+ str | None,
58
+ typer.Option("--profile", "-p", help="Profile to use"),
59
+ ] = None,
60
+ ) -> None:
61
+ """List recipients for a domain."""
62
+ try:
63
+ with FastSMTPClient(profile_name=profile) as client:
64
+ recipients = client.list_recipients(domain_id, include_deleted=include_deleted)
65
+ if not recipients:
66
+ print_error("No recipients found")
67
+ return
68
+ print_recipients_table(recipients)
69
+ except APIError as e:
70
+ print_error(e.detail)
71
+ raise typer.Exit(1) from e
72
+
73
+
74
+ @app.command("get")
75
+ def get_recipient(
76
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
77
+ recipient_id: Annotated[str, typer.Argument(help="Recipient ID")],
78
+ include_deleted: IncludeDeleted = False,
79
+ profile: Annotated[
80
+ str | None,
81
+ typer.Option("--profile", "-p", help="Profile to use"),
82
+ ] = None,
83
+ ) -> None:
84
+ """Get recipient details (a deleted recipient is not found without --include-deleted)."""
85
+ try:
86
+ with FastSMTPClient(profile_name=profile) as client:
87
+ recipient = client.get_recipient(
88
+ domain_id, recipient_id, include_deleted=include_deleted
89
+ )
90
+ print_recipient(recipient)
91
+ except APIError as e:
92
+ print_error(e.detail)
93
+ raise typer.Exit(1) from e
94
+
95
+
96
+ @app.command("create")
97
+ def create_recipient(
98
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
99
+ webhook_url: Annotated[str, typer.Argument(help="Webhook URL")],
100
+ local_part: Annotated[
101
+ str | None,
102
+ typer.Option("--local", "-l", help="Local part (omit for catch-all)"),
103
+ ] = None,
104
+ headers: HeaderOption = None,
105
+ profile: Annotated[
106
+ str | None,
107
+ typer.Option("--profile", "-p", help="Profile to use"),
108
+ ] = None,
109
+ ) -> None:
110
+ """Create a new recipient."""
111
+ webhook_headers = parse_headers(headers)
112
+
113
+ try:
114
+ with FastSMTPClient(profile_name=profile) as client:
115
+ recipient = client.create_recipient(
116
+ domain_id=domain_id,
117
+ webhook_url=webhook_url,
118
+ local_part=local_part,
119
+ webhook_headers=webhook_headers,
120
+ )
121
+ address = local_part if local_part else "* (catch-all)"
122
+ print_success(f"Recipient '{address}' created")
123
+ print_recipient(recipient)
124
+ except APIError as e:
125
+ print_error(e.detail)
126
+ raise typer.Exit(1) from e
127
+
128
+
129
+ @app.command("update")
130
+ def update_recipient(
131
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
132
+ recipient_id: Annotated[str, typer.Argument(help="Recipient ID")],
133
+ local_part: Annotated[
134
+ str | None,
135
+ typer.Option("--local", "-l", help="Local part; pass '' to clear it (catch-all)"),
136
+ ] = None,
137
+ webhook_url: Annotated[
138
+ str | None,
139
+ typer.Option("--webhook", "-w", help="Webhook URL"),
140
+ ] = None,
141
+ enabled: Annotated[
142
+ bool | None,
143
+ typer.Option("--enabled/--disabled", help="Enable or disable recipient"),
144
+ ] = None,
145
+ headers: HeaderOption = None,
146
+ profile: Annotated[
147
+ str | None,
148
+ typer.Option("--profile", "-p", help="Profile to use"),
149
+ ] = None,
150
+ ) -> None:
151
+ """Update a recipient."""
152
+ if all(value is None for value in [local_part, webhook_url, enabled, headers]):
153
+ print_error("At least one option must be provided")
154
+ raise typer.Exit(1)
155
+
156
+ webhook_headers = parse_headers(headers)
157
+
158
+ try:
159
+ with FastSMTPClient(profile_name=profile) as client:
160
+ recipient = client.update_recipient(
161
+ domain_id=domain_id,
162
+ recipient_id=recipient_id,
163
+ local_part=clearable_str(local_part),
164
+ webhook_url=webhook_url,
165
+ is_enabled=enabled,
166
+ webhook_headers=webhook_headers,
167
+ )
168
+ print_success("Recipient updated")
169
+ print_recipient(recipient)
170
+ except APIError as e:
171
+ print_error(e.detail)
172
+ raise typer.Exit(1) from e
173
+
174
+
175
+ @app.command("delete")
176
+ def delete_recipient(
177
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
178
+ recipient_id: Annotated[str, typer.Argument(help="Recipient ID")],
179
+ force: Annotated[
180
+ bool,
181
+ typer.Option("--force", "-f", help="Skip confirmation"),
182
+ ] = False,
183
+ purge: Purge = False,
184
+ profile: Annotated[
185
+ str | None,
186
+ typer.Option("--profile", "-p", help="Profile to use"),
187
+ ] = None,
188
+ ) -> None:
189
+ """Delete a recipient (restorable), or permanently purge an already-deleted one."""
190
+ confirm_delete("recipient", recipient_id, force=force, purge=purge)
191
+
192
+ try:
193
+ with FastSMTPClient(profile_name=profile) as client:
194
+ client.delete_recipient(domain_id, recipient_id, purge=purge)
195
+ print_deleted(
196
+ "recipient",
197
+ recipient_id,
198
+ purge=purge,
199
+ restore_command=f"fsmtp recipient restore {domain_id} {recipient_id}",
200
+ )
201
+ except APIError as e:
202
+ print_error(e.detail)
203
+ raise typer.Exit(1) from e
204
+
205
+
206
+ @app.command("restore")
207
+ def restore_recipient(
208
+ domain_id: Annotated[str, typer.Argument(help="Domain ID")],
209
+ recipient_id: Annotated[str, typer.Argument(help="Recipient ID")],
210
+ profile: Annotated[
211
+ str | None,
212
+ typer.Option("--profile", "-p", help="Profile to use"),
213
+ ] = None,
214
+ ) -> None:
215
+ """Restore a deleted recipient (the domain itself must not be deleted)."""
216
+ try:
217
+ with FastSMTPClient(profile_name=profile) as client:
218
+ recipient = client.restore_recipient(domain_id, recipient_id)
219
+ print_success("Recipient restored")
220
+ print_recipient(recipient)
221
+ except APIError as e:
222
+ print_error(e.detail)
223
+ raise typer.Exit(1) from e