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/output.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Output formatting utilities for CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.json import JSON
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
error_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def field(value: Any, placeholder: str = "-") -> str:
|
|
20
|
+
"""Render a server-supplied value as literal text.
|
|
21
|
+
|
|
22
|
+
Rich parses plain strings as markup, so a value containing square brackets
|
|
23
|
+
would restyle the output, or crash the command with ``MarkupError`` on an
|
|
24
|
+
unmatched closing tag. Escaping here keeps server text literal while the
|
|
25
|
+
markup the formatters build deliberately (``yes_no``, ``tri_state``, the
|
|
26
|
+
``status_style`` wrappers) still parses. ``None`` and empty values render
|
|
27
|
+
as the placeholder.
|
|
28
|
+
"""
|
|
29
|
+
if value is None or value == "":
|
|
30
|
+
return placeholder
|
|
31
|
+
return escape(str(value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def short_id(value: Any) -> str:
|
|
35
|
+
"""Render the first 8 characters of a server identifier."""
|
|
36
|
+
if value is None or value == "":
|
|
37
|
+
return "-"
|
|
38
|
+
return field(str(value)[:8] + "...")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_datetime(value: str) -> datetime | None:
|
|
42
|
+
"""Parse a server timestamp string, or ``None`` when it is not ISO 8601."""
|
|
43
|
+
try:
|
|
44
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
45
|
+
except ValueError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_datetime(dt: str | datetime | None) -> str:
|
|
50
|
+
"""Format a datetime for display."""
|
|
51
|
+
if dt is None:
|
|
52
|
+
return "-"
|
|
53
|
+
if isinstance(dt, str):
|
|
54
|
+
parsed = _parse_datetime(dt)
|
|
55
|
+
if parsed is None:
|
|
56
|
+
# Not a timestamp we understand -- show the server's text literally.
|
|
57
|
+
return escape(dt)
|
|
58
|
+
else:
|
|
59
|
+
parsed = dt
|
|
60
|
+
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_expiry(expires: str | datetime | None) -> str:
|
|
64
|
+
"""Render an API key's expiry as a fully-styled cell.
|
|
65
|
+
|
|
66
|
+
Shared by ``print_api_keys_table`` and ``print_api_key`` so the two views
|
|
67
|
+
cannot drift. ``None`` means the key never expires. The timestamp is parsed
|
|
68
|
+
once and reused for both the expired-vs-not comparison and the display; a
|
|
69
|
+
value the server sends that is not a timestamp is shown literally (escaped,
|
|
70
|
+
like ``format_datetime``'s fallback) with no expiry styling, instead of
|
|
71
|
+
crashing the command.
|
|
72
|
+
"""
|
|
73
|
+
if not expires:
|
|
74
|
+
return "[dim]Never[/dim]"
|
|
75
|
+
if isinstance(expires, str):
|
|
76
|
+
parsed = _parse_datetime(expires)
|
|
77
|
+
if parsed is None:
|
|
78
|
+
return field(expires)
|
|
79
|
+
else:
|
|
80
|
+
parsed = expires
|
|
81
|
+
formatted = format_datetime(parsed)
|
|
82
|
+
if parsed < datetime.now(parsed.tzinfo):
|
|
83
|
+
return f"[red]{formatted}[/red]"
|
|
84
|
+
return formatted
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_deleted(value: str | datetime | None) -> str:
|
|
88
|
+
"""Render a ``deleted_at`` tombstone as a red cell, or a dash when live.
|
|
89
|
+
|
|
90
|
+
Non-ISO server text falls through ``format_datetime``'s escaping fallback,
|
|
91
|
+
so the styling wrapper parses while the value inside stays literal.
|
|
92
|
+
"""
|
|
93
|
+
if not value:
|
|
94
|
+
return "-"
|
|
95
|
+
return f"[red]{format_datetime(value)}[/red]"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tombstone(row: dict) -> Any:
|
|
99
|
+
"""The ``deleted_at`` value of a server row, or a falsy value when live."""
|
|
100
|
+
return row.get("deleted_at")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _key_tombstone(row: dict) -> Any:
|
|
104
|
+
"""An API key's tombstone, counting keys retired before soft delete existed.
|
|
105
|
+
|
|
106
|
+
Those carry ``is_active`` false with no ``deleted_at``; the server lists
|
|
107
|
+
them under ``include_deleted`` because they cannot authenticate either.
|
|
108
|
+
"""
|
|
109
|
+
return row.get("deleted_at") or ("retired" if row.get("is_active") is False else None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _add_rows(
|
|
113
|
+
table: Table,
|
|
114
|
+
rows: list[dict],
|
|
115
|
+
cells: Callable[[dict], list[str]],
|
|
116
|
+
tombstone: Callable[[dict], Any] = _tombstone,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Add one table row per server row, plus a data-driven ``Deleted`` column.
|
|
119
|
+
|
|
120
|
+
The server only returns tombstones when asked (``--include-deleted``), so
|
|
121
|
+
the column and the ``dim`` row styling appear only when a row actually
|
|
122
|
+
carries one; a live-only listing renders exactly as it did before soft
|
|
123
|
+
delete existed. Nothing needs to thread a flag through. ``tombstone``
|
|
124
|
+
reads the value to show; non-timestamp text such as ``"retired"`` goes
|
|
125
|
+
through ``format_deleted``'s literal fallback.
|
|
126
|
+
"""
|
|
127
|
+
show_deleted = any(tombstone(row) for row in rows)
|
|
128
|
+
if show_deleted:
|
|
129
|
+
table.add_column("Deleted")
|
|
130
|
+
for row in rows:
|
|
131
|
+
values = cells(row)
|
|
132
|
+
deleted_at = tombstone(row)
|
|
133
|
+
if show_deleted:
|
|
134
|
+
values.append(format_deleted(deleted_at))
|
|
135
|
+
table.add_row(*values, style="dim" if deleted_at else None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _add_deleted_row(table: Table, row: dict) -> None:
|
|
139
|
+
"""Add a ``Deleted`` row to a detail view, only when the row is tombstoned."""
|
|
140
|
+
if row.get("deleted_at"):
|
|
141
|
+
table.add_row("Deleted", format_deleted(row["deleted_at"]))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def truncate(text: str | None, max_length: int = 50) -> str:
|
|
145
|
+
"""Truncate text with ellipsis."""
|
|
146
|
+
if text is None:
|
|
147
|
+
return "-"
|
|
148
|
+
if len(text) <= max_length:
|
|
149
|
+
return text
|
|
150
|
+
return text[: max_length - 3] + "..."
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def status_style(status: str) -> str:
|
|
154
|
+
"""Get rich style for a status string."""
|
|
155
|
+
status_lower = status.lower()
|
|
156
|
+
if status_lower in ("ok", "pass", "success", "delivered", "active", "enabled"):
|
|
157
|
+
return "green"
|
|
158
|
+
if status_lower in ("fail", "failed", "error", "exhausted", "disabled"):
|
|
159
|
+
return "red"
|
|
160
|
+
if status_lower in ("pending", "queued", "retrying", "warning", "cancelled"):
|
|
161
|
+
return "yellow"
|
|
162
|
+
return "white"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def yes_no(value: Any) -> str:
|
|
166
|
+
"""Render a boolean as coloured Yes/No."""
|
|
167
|
+
return "[green]Yes[/green]" if value else "[red]No[/red]"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def tri_state(value: bool | None) -> str:
|
|
171
|
+
"""Render a nullable server setting, where null means "inherit the default"."""
|
|
172
|
+
if value is None:
|
|
173
|
+
return "[dim]inherit[/dim]"
|
|
174
|
+
return "[green]Yes[/green]" if value else "[red]No[/red]"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def format_mapping(mapping: dict | None) -> str:
|
|
178
|
+
"""Render a string mapping (webhook headers) on one line."""
|
|
179
|
+
if not mapping:
|
|
180
|
+
return "-"
|
|
181
|
+
return ", ".join(f"{key}={value}" for key, value in mapping.items())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def print_json(data: Any) -> None:
|
|
185
|
+
"""Print data as formatted JSON."""
|
|
186
|
+
console.print(JSON(json.dumps(data, indent=2, default=str)))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def print_error(message: str) -> None:
|
|
190
|
+
"""Print an error message.
|
|
191
|
+
|
|
192
|
+
Server details can contain square brackets (validation messages list valid
|
|
193
|
+
values that way), so the message is escaped rather than parsed as markup.
|
|
194
|
+
"""
|
|
195
|
+
error_console.print(f"[red]Error:[/red] {escape(str(message))}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def print_success(message: str) -> None:
|
|
199
|
+
"""Print a success message."""
|
|
200
|
+
console.print(f"[green]{escape(str(message))}[/green]")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def print_warning(message: str) -> None:
|
|
204
|
+
"""Print a warning message."""
|
|
205
|
+
console.print(f"[yellow]{escape(str(message))}[/yellow]")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def print_info(message: str) -> None:
|
|
209
|
+
"""Print an info message."""
|
|
210
|
+
console.print(f"[blue]{escape(str(message))}[/blue]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def print_deleted(kind: str, label: str, *, purge: bool, restore_command: str) -> None:
|
|
214
|
+
"""Report a delete: purged for good, or soft-deleted with the restore hint.
|
|
215
|
+
|
|
216
|
+
Shared by the user, domain and recipient ``delete`` commands so the two
|
|
217
|
+
outcomes read the same everywhere. ``kind`` is the lowercase resource
|
|
218
|
+
name (``"domain"``); ``restore_command`` is the full ``fsmtp ...`` line.
|
|
219
|
+
"""
|
|
220
|
+
print_success(f"{kind.capitalize()} {label} {'purged' if purge else 'deleted'}")
|
|
221
|
+
if not purge:
|
|
222
|
+
print_info(f"Restore with: {restore_command}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# Table formatters for different resource types
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def print_health(data: dict) -> None:
|
|
229
|
+
"""Print health check result."""
|
|
230
|
+
status = data.get("status", "unknown")
|
|
231
|
+
style = status_style(status)
|
|
232
|
+
|
|
233
|
+
panel = Panel(
|
|
234
|
+
f"[{style}]Status: {field(status)}[/{style}]\n"
|
|
235
|
+
f"Version: {field(data.get('version', 'unknown'))}\n"
|
|
236
|
+
f"Instance: {field(data.get('instance_id', 'unknown'))}",
|
|
237
|
+
title="Health Check",
|
|
238
|
+
)
|
|
239
|
+
console.print(panel)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def print_ready(data: dict) -> None:
|
|
243
|
+
"""Print readiness check result."""
|
|
244
|
+
status = data.get("status", "unknown")
|
|
245
|
+
db_status = data.get("database", "unknown")
|
|
246
|
+
|
|
247
|
+
panel = Panel(
|
|
248
|
+
f"[{status_style(status)}]Status: {field(status)}[/{status_style(status)}]\n"
|
|
249
|
+
f"[{status_style(db_status)}]Database: {field(db_status)}[/{status_style(db_status)}]",
|
|
250
|
+
title="Readiness Check",
|
|
251
|
+
)
|
|
252
|
+
console.print(panel)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def print_whoami(data: dict) -> None:
|
|
256
|
+
"""Print current user info (server schema: user, domains, is_root)."""
|
|
257
|
+
user = data.get("user", {})
|
|
258
|
+
|
|
259
|
+
table = Table(show_header=False, box=None)
|
|
260
|
+
table.add_column("Field", style="cyan")
|
|
261
|
+
table.add_column("Value")
|
|
262
|
+
|
|
263
|
+
table.add_row("User ID", field(user.get("id")))
|
|
264
|
+
table.add_row("Username", field(user.get("username")))
|
|
265
|
+
table.add_row("Email", field(user.get("email")))
|
|
266
|
+
table.add_row("Superuser", yes_no(user.get("is_superuser")))
|
|
267
|
+
table.add_row("Root Key", yes_no(data.get("is_root")))
|
|
268
|
+
table.add_row("Domains", field(", ".join(data.get("domains", []))))
|
|
269
|
+
|
|
270
|
+
console.print(Panel(table, title="Current Session"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def print_users_table(users: list[dict]) -> None:
|
|
274
|
+
"""Print users as a table."""
|
|
275
|
+
table = Table(title="Users")
|
|
276
|
+
|
|
277
|
+
table.add_column("ID", style="dim")
|
|
278
|
+
table.add_column("Username")
|
|
279
|
+
table.add_column("Email")
|
|
280
|
+
table.add_column("Superuser")
|
|
281
|
+
table.add_column("Active")
|
|
282
|
+
table.add_column("Created")
|
|
283
|
+
|
|
284
|
+
_add_rows(
|
|
285
|
+
table,
|
|
286
|
+
users,
|
|
287
|
+
lambda user: [
|
|
288
|
+
short_id(user.get("id")),
|
|
289
|
+
field(user.get("username")),
|
|
290
|
+
field(user.get("email")),
|
|
291
|
+
yes_no(user.get("is_superuser")),
|
|
292
|
+
yes_no(user.get("is_active")),
|
|
293
|
+
format_datetime(user.get("created_at")),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
console.print(table)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def print_user(user: dict) -> None:
|
|
301
|
+
"""Print a single user."""
|
|
302
|
+
table = Table(show_header=False, box=None)
|
|
303
|
+
table.add_column("Field", style="cyan")
|
|
304
|
+
table.add_column("Value")
|
|
305
|
+
|
|
306
|
+
table.add_row("ID", field(user.get("id")))
|
|
307
|
+
table.add_row("Username", field(user.get("username")))
|
|
308
|
+
table.add_row("Email", field(user.get("email")))
|
|
309
|
+
table.add_row("Superuser", yes_no(user.get("is_superuser")))
|
|
310
|
+
table.add_row("Active", yes_no(user.get("is_active")))
|
|
311
|
+
table.add_row("Created", format_datetime(user.get("created_at")))
|
|
312
|
+
table.add_row("Updated", format_datetime(user.get("updated_at")))
|
|
313
|
+
_add_deleted_row(table, user)
|
|
314
|
+
|
|
315
|
+
console.print(Panel(table, title="User Details"))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def print_api_keys_table(keys: list[dict]) -> None:
|
|
319
|
+
"""Print API keys as a table."""
|
|
320
|
+
table = Table(title="API Keys")
|
|
321
|
+
|
|
322
|
+
table.add_column("ID", style="dim")
|
|
323
|
+
table.add_column("Name")
|
|
324
|
+
table.add_column("Scopes")
|
|
325
|
+
table.add_column("Expires")
|
|
326
|
+
table.add_column("Last Used")
|
|
327
|
+
table.add_column("Created")
|
|
328
|
+
|
|
329
|
+
_add_rows(
|
|
330
|
+
table,
|
|
331
|
+
keys,
|
|
332
|
+
lambda key: [
|
|
333
|
+
short_id(key.get("id")),
|
|
334
|
+
field(key.get("name")),
|
|
335
|
+
field(truncate(", ".join(key.get("scopes", [])), 30), placeholder="all"),
|
|
336
|
+
_format_expiry(key.get("expires_at")),
|
|
337
|
+
format_datetime(key.get("last_used_at")) if key.get("last_used_at") else "-",
|
|
338
|
+
format_datetime(key.get("created_at")),
|
|
339
|
+
],
|
|
340
|
+
tombstone=_key_tombstone,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
console.print(table)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def print_api_key(key: dict, show_secret: bool = False) -> None:
|
|
347
|
+
"""Print a single API key."""
|
|
348
|
+
table = Table(show_header=False, box=None)
|
|
349
|
+
table.add_column("Field", style="cyan")
|
|
350
|
+
table.add_column("Value")
|
|
351
|
+
|
|
352
|
+
table.add_row("ID", field(key.get("id")))
|
|
353
|
+
table.add_row("Name", field(key.get("name")))
|
|
354
|
+
table.add_row("Scopes", field(", ".join(key.get("scopes", [])), placeholder="all"))
|
|
355
|
+
table.add_row("Expires", _format_expiry(key.get("expires_at")))
|
|
356
|
+
table.add_row("Created", format_datetime(key.get("created_at")))
|
|
357
|
+
_add_deleted_row(table, key)
|
|
358
|
+
|
|
359
|
+
if show_secret and key.get("key"):
|
|
360
|
+
table.add_row("", "")
|
|
361
|
+
table.add_row("[bold yellow]API Key[/bold yellow]", f"[bold]{escape(key['key'])}[/bold]")
|
|
362
|
+
table.add_row("", "[dim]Save this key - it won't be shown again![/dim]")
|
|
363
|
+
|
|
364
|
+
console.print(Panel(table, title="API Key"))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def print_domains_table(domains: list[dict]) -> None:
|
|
368
|
+
"""Print domains as a table."""
|
|
369
|
+
table = Table(title="Domains")
|
|
370
|
+
|
|
371
|
+
table.add_column("ID", style="dim")
|
|
372
|
+
table.add_column("Domain")
|
|
373
|
+
table.add_column("Enabled")
|
|
374
|
+
table.add_column("DKIM")
|
|
375
|
+
table.add_column("SPF")
|
|
376
|
+
table.add_column("Preserve Raw")
|
|
377
|
+
table.add_column("Created")
|
|
378
|
+
|
|
379
|
+
_add_rows(
|
|
380
|
+
table,
|
|
381
|
+
domains,
|
|
382
|
+
lambda domain: [
|
|
383
|
+
short_id(domain.get("id")),
|
|
384
|
+
field(domain.get("domain_name")),
|
|
385
|
+
yes_no(domain.get("is_enabled")),
|
|
386
|
+
tri_state(domain.get("verify_dkim")),
|
|
387
|
+
tri_state(domain.get("verify_spf")),
|
|
388
|
+
tri_state(domain.get("preserve_raw_message")),
|
|
389
|
+
format_datetime(domain.get("created_at")),
|
|
390
|
+
],
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
console.print(table)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def print_domain(domain: dict) -> None:
|
|
397
|
+
"""Print a single domain."""
|
|
398
|
+
table = Table(show_header=False, box=None)
|
|
399
|
+
table.add_column("Field", style="cyan")
|
|
400
|
+
table.add_column("Value")
|
|
401
|
+
|
|
402
|
+
table.add_row("ID", field(domain.get("id")))
|
|
403
|
+
table.add_row("Domain", field(domain.get("domain_name")))
|
|
404
|
+
table.add_row("Enabled", yes_no(domain.get("is_enabled")))
|
|
405
|
+
table.add_row("Verify DKIM", tri_state(domain.get("verify_dkim")))
|
|
406
|
+
table.add_row("Verify SPF", tri_state(domain.get("verify_spf")))
|
|
407
|
+
table.add_row("Reject DKIM Fail", tri_state(domain.get("reject_dkim_fail")))
|
|
408
|
+
table.add_row("Reject SPF Fail", tri_state(domain.get("reject_spf_fail")))
|
|
409
|
+
table.add_row("Preserve Raw", tri_state(domain.get("preserve_raw_message")))
|
|
410
|
+
table.add_row("Created", format_datetime(domain.get("created_at")))
|
|
411
|
+
table.add_row("Updated", format_datetime(domain.get("updated_at")))
|
|
412
|
+
_add_deleted_row(table, domain)
|
|
413
|
+
|
|
414
|
+
console.print(Panel(table, title="Domain Details"))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def print_members_table(members: list[dict]) -> None:
|
|
418
|
+
"""Print domain members as a table."""
|
|
419
|
+
table = Table(title="Domain Members")
|
|
420
|
+
|
|
421
|
+
table.add_column("User ID", style="dim")
|
|
422
|
+
table.add_column("Username")
|
|
423
|
+
table.add_column("Role")
|
|
424
|
+
table.add_column("Joined")
|
|
425
|
+
|
|
426
|
+
for member in members:
|
|
427
|
+
role = member.get("role", "-")
|
|
428
|
+
role_style = "yellow" if role == "owner" else "cyan" if role == "admin" else "white"
|
|
429
|
+
|
|
430
|
+
table.add_row(
|
|
431
|
+
short_id(member.get("user_id")),
|
|
432
|
+
field(member.get("username")),
|
|
433
|
+
f"[{role_style}]{field(role)}[/{role_style}]",
|
|
434
|
+
format_datetime(member.get("created_at")),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
console.print(table)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _address(local_part: str | None) -> str:
|
|
441
|
+
"""A recipient's address cell: the local part, or the catch-all marker."""
|
|
442
|
+
return field(local_part) if local_part else "[dim]*[/dim] (catch-all)"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def print_recipients_table(recipients: list[dict]) -> None:
|
|
446
|
+
"""Print recipients as a table."""
|
|
447
|
+
table = Table(title="Recipients")
|
|
448
|
+
|
|
449
|
+
table.add_column("ID", style="dim")
|
|
450
|
+
table.add_column("Address")
|
|
451
|
+
table.add_column("Webhook URL")
|
|
452
|
+
table.add_column("Headers")
|
|
453
|
+
table.add_column("Enabled")
|
|
454
|
+
|
|
455
|
+
_add_rows(
|
|
456
|
+
table,
|
|
457
|
+
recipients,
|
|
458
|
+
lambda recipient: [
|
|
459
|
+
short_id(recipient.get("id")),
|
|
460
|
+
_address(recipient.get("local_part")),
|
|
461
|
+
field(truncate(recipient.get("webhook_url"), 40)),
|
|
462
|
+
field(truncate(format_mapping(recipient.get("webhook_headers")), 20)),
|
|
463
|
+
yes_no(recipient.get("is_enabled")),
|
|
464
|
+
],
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
console.print(table)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def print_recipient(recipient: dict) -> None:
|
|
471
|
+
"""Print a single recipient."""
|
|
472
|
+
table = Table(show_header=False, box=None)
|
|
473
|
+
table.add_column("Field", style="cyan")
|
|
474
|
+
table.add_column("Value")
|
|
475
|
+
|
|
476
|
+
local_part = recipient.get("local_part")
|
|
477
|
+
|
|
478
|
+
table.add_row("ID", field(recipient.get("id")))
|
|
479
|
+
table.add_row("Local Part", field(local_part) if local_part else "[dim]* (catch-all)[/dim]")
|
|
480
|
+
table.add_row("Webhook URL", field(recipient.get("webhook_url")))
|
|
481
|
+
table.add_row("Webhook Headers", field(format_mapping(recipient.get("webhook_headers"))))
|
|
482
|
+
table.add_row("Enabled", yes_no(recipient.get("is_enabled")))
|
|
483
|
+
table.add_row("Created", format_datetime(recipient.get("created_at")))
|
|
484
|
+
_add_deleted_row(table, recipient)
|
|
485
|
+
|
|
486
|
+
console.print(Panel(table, title="Recipient Details"))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def print_rulesets_table(rulesets: list[dict]) -> None:
|
|
490
|
+
"""Print rulesets as a table."""
|
|
491
|
+
table = Table(title="RuleSets")
|
|
492
|
+
|
|
493
|
+
table.add_column("ID", style="dim")
|
|
494
|
+
table.add_column("Name")
|
|
495
|
+
table.add_column("Priority")
|
|
496
|
+
table.add_column("Stop on Match")
|
|
497
|
+
table.add_column("Rules")
|
|
498
|
+
table.add_column("Enabled")
|
|
499
|
+
|
|
500
|
+
for ruleset in rulesets:
|
|
501
|
+
table.add_row(
|
|
502
|
+
short_id(ruleset.get("id")),
|
|
503
|
+
field(ruleset.get("name")),
|
|
504
|
+
str(ruleset.get("priority", 0)),
|
|
505
|
+
yes_no(ruleset.get("stop_on_match")),
|
|
506
|
+
str(len(ruleset.get("rules", []))),
|
|
507
|
+
yes_no(ruleset.get("is_enabled")),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
console.print(table)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def print_ruleset(ruleset: dict) -> None:
|
|
514
|
+
"""Print a single ruleset with its rules."""
|
|
515
|
+
table = Table(show_header=False, box=None)
|
|
516
|
+
table.add_column("Field", style="cyan")
|
|
517
|
+
table.add_column("Value")
|
|
518
|
+
|
|
519
|
+
table.add_row("ID", field(ruleset.get("id")))
|
|
520
|
+
table.add_row("Name", field(ruleset.get("name")))
|
|
521
|
+
table.add_row("Priority", str(ruleset.get("priority", 0)))
|
|
522
|
+
table.add_row("Stop on Match", yes_no(ruleset.get("stop_on_match")))
|
|
523
|
+
table.add_row("Enabled", yes_no(ruleset.get("is_enabled")))
|
|
524
|
+
table.add_row("Created", format_datetime(ruleset.get("created_at")))
|
|
525
|
+
|
|
526
|
+
console.print(Panel(table, title="RuleSet Details"))
|
|
527
|
+
|
|
528
|
+
# Print rules if present
|
|
529
|
+
rules = ruleset.get("rules", [])
|
|
530
|
+
if rules:
|
|
531
|
+
print_rules_table(rules)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def print_rules_table(rules: list[dict]) -> None:
|
|
535
|
+
"""Print rules as a table."""
|
|
536
|
+
table = Table(title="Rules")
|
|
537
|
+
|
|
538
|
+
table.add_column("ID", style="dim")
|
|
539
|
+
table.add_column("Order")
|
|
540
|
+
table.add_column("Condition")
|
|
541
|
+
table.add_column("Action")
|
|
542
|
+
table.add_column("Tags")
|
|
543
|
+
table.add_column("Preserve Raw")
|
|
544
|
+
|
|
545
|
+
for rule in rules:
|
|
546
|
+
field_name = rule.get("field", "-")
|
|
547
|
+
op = rule.get("operator", "-")
|
|
548
|
+
val = rule.get("value", "-")
|
|
549
|
+
condition = f"{field_name} {op} '{val}'"
|
|
550
|
+
|
|
551
|
+
table.add_row(
|
|
552
|
+
short_id(rule.get("id")),
|
|
553
|
+
str(rule.get("order", 0)),
|
|
554
|
+
field(truncate(condition, 35)),
|
|
555
|
+
field(rule.get("action")),
|
|
556
|
+
field(", ".join(rule.get("add_tags") or [])),
|
|
557
|
+
yes_no(rule.get("preserve_raw")),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
console.print(table)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def print_rule(rule: dict) -> None:
|
|
564
|
+
"""Print a single rule."""
|
|
565
|
+
table = Table(show_header=False, box=None)
|
|
566
|
+
table.add_column("Field", style="cyan")
|
|
567
|
+
table.add_column("Value")
|
|
568
|
+
|
|
569
|
+
table.add_row("ID", field(rule.get("id")))
|
|
570
|
+
table.add_row("RuleSet ID", field(rule.get("ruleset_id")))
|
|
571
|
+
table.add_row("Order", str(rule.get("order", 0)))
|
|
572
|
+
table.add_row("Field", field(rule.get("field")))
|
|
573
|
+
table.add_row("Operator", field(rule.get("operator")))
|
|
574
|
+
table.add_row("Value", field(rule.get("value")))
|
|
575
|
+
table.add_row("Case Sensitive", yes_no(rule.get("case_sensitive")))
|
|
576
|
+
table.add_row("Action", field(rule.get("action")))
|
|
577
|
+
table.add_row("Webhook Override", field(rule.get("webhook_url_override")))
|
|
578
|
+
table.add_row("Add Tags", field(", ".join(rule.get("add_tags") or [])))
|
|
579
|
+
table.add_row("Preserve Raw", yes_no(rule.get("preserve_raw")))
|
|
580
|
+
table.add_row("Created", format_datetime(rule.get("created_at")))
|
|
581
|
+
|
|
582
|
+
console.print(Panel(table, title="Rule Details"))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def print_delivery_logs_table(logs: list[dict]) -> None:
|
|
586
|
+
"""Print delivery logs as a table."""
|
|
587
|
+
table = Table(title="Delivery Logs")
|
|
588
|
+
|
|
589
|
+
table.add_column("ID", style="dim")
|
|
590
|
+
table.add_column("Message ID")
|
|
591
|
+
table.add_column("Recipient")
|
|
592
|
+
table.add_column("Status")
|
|
593
|
+
table.add_column("Attempts")
|
|
594
|
+
table.add_column("Created")
|
|
595
|
+
|
|
596
|
+
for log in logs:
|
|
597
|
+
status = log.get("status", "-")
|
|
598
|
+
|
|
599
|
+
table.add_row(
|
|
600
|
+
short_id(log.get("id")),
|
|
601
|
+
field(truncate(log.get("message_id"), 25)),
|
|
602
|
+
short_id(log.get("recipient_id")),
|
|
603
|
+
f"[{status_style(status)}]{field(status)}[/{status_style(status)}]",
|
|
604
|
+
str(log.get("attempts", 0)),
|
|
605
|
+
format_datetime(log.get("created_at")),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
console.print(table)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def print_delivery_log(log: dict) -> None:
|
|
612
|
+
"""Print a single delivery log."""
|
|
613
|
+
table = Table(show_header=False, box=None)
|
|
614
|
+
table.add_column("Field", style="cyan")
|
|
615
|
+
table.add_column("Value")
|
|
616
|
+
|
|
617
|
+
status = log.get("status", "-")
|
|
618
|
+
|
|
619
|
+
table.add_row("ID", field(log.get("id")))
|
|
620
|
+
table.add_row("Message ID", field(log.get("message_id")))
|
|
621
|
+
table.add_row("Recipient ID", field(log.get("recipient_id")))
|
|
622
|
+
table.add_row("Webhook URL", field(log.get("webhook_url")))
|
|
623
|
+
table.add_row("Status", f"[{status_style(status)}]{field(status)}[/{status_style(status)}]")
|
|
624
|
+
table.add_row("Attempts", str(log.get("attempts", 0)))
|
|
625
|
+
table.add_row("HTTP Status", str(log.get("last_status_code") or "-"))
|
|
626
|
+
table.add_row("DKIM", field(log.get("dkim_result")))
|
|
627
|
+
table.add_row("SPF", field(log.get("spf_result")))
|
|
628
|
+
table.add_row("Error", field(log.get("last_error")))
|
|
629
|
+
table.add_row("Created", format_datetime(log.get("created_at")))
|
|
630
|
+
table.add_row("Next Retry", format_datetime(log.get("next_retry_at")))
|
|
631
|
+
|
|
632
|
+
console.print(Panel(table, title="Delivery Log Details"))
|
|
633
|
+
|
|
634
|
+
# Print payload if present
|
|
635
|
+
payload = log.get("payload")
|
|
636
|
+
if payload:
|
|
637
|
+
console.print("\n[bold]Payload:[/bold]")
|
|
638
|
+
print_json(payload)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def print_test_webhook_result(result: dict) -> None:
|
|
642
|
+
"""Print test webhook result."""
|
|
643
|
+
success = result.get("success", False)
|
|
644
|
+
status_code = result.get("status_code")
|
|
645
|
+
error = result.get("error")
|
|
646
|
+
response_time = result.get("response_time_ms") or 0
|
|
647
|
+
|
|
648
|
+
if success:
|
|
649
|
+
text = Text()
|
|
650
|
+
text.append("SUCCESS", style="bold green")
|
|
651
|
+
text.append(f"\nStatus Code: {status_code}")
|
|
652
|
+
text.append(f"\nResponse Time: {response_time:.2f}ms")
|
|
653
|
+
else:
|
|
654
|
+
text = Text()
|
|
655
|
+
text.append("FAILED", style="bold red")
|
|
656
|
+
if status_code:
|
|
657
|
+
text.append(f"\nStatus Code: {status_code}")
|
|
658
|
+
if error:
|
|
659
|
+
text.append(f"\nError: {error}")
|
|
660
|
+
text.append(f"\nResponse Time: {response_time:.2f}ms")
|
|
661
|
+
|
|
662
|
+
console.print(Panel(text, title="Webhook Test Result"))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def print_profiles_table(
|
|
666
|
+
profiles: dict,
|
|
667
|
+
default_profile: str,
|
|
668
|
+
show_keys: bool = False,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Print profiles as a table."""
|
|
671
|
+
table = Table(title="Profiles")
|
|
672
|
+
|
|
673
|
+
table.add_column("Name")
|
|
674
|
+
table.add_column("URL")
|
|
675
|
+
table.add_column("Timeout")
|
|
676
|
+
table.add_column("SSL Verify")
|
|
677
|
+
if show_keys:
|
|
678
|
+
table.add_column("API Key")
|
|
679
|
+
|
|
680
|
+
for name, profile in profiles.items():
|
|
681
|
+
is_default = name == default_profile
|
|
682
|
+
name_display = f"[bold]{field(name)}[/bold] (default)" if is_default else field(name)
|
|
683
|
+
|
|
684
|
+
row = [
|
|
685
|
+
name_display,
|
|
686
|
+
field(profile.url),
|
|
687
|
+
f"{profile.timeout}s",
|
|
688
|
+
"[green]Yes[/green]" if profile.verify_ssl else "[red]No[/red]",
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
if show_keys:
|
|
692
|
+
key = profile.api_key
|
|
693
|
+
if key:
|
|
694
|
+
row.append(field(truncate(key, 20)))
|
|
695
|
+
else:
|
|
696
|
+
row.append("[dim]Not set[/dim]")
|
|
697
|
+
|
|
698
|
+
table.add_row(*row)
|
|
699
|
+
|
|
700
|
+
console.print(table)
|