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