diff --git a/README.md b/README.md index 0f0bc4b..c5b837c 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,42 @@ The tacomail CLI provides comprehensive command-line interface for Tacomail disp ### Global Options +- `--output`, `-o` - Output format: `rich` (default), `plain`, or `json` - `--async` - Use async client instead of sync - `--verbose` - Enable verbose/debug output - `--help` - Show help message +### Output Formats + +The CLI supports three output formats via the `--output` option: + +**Rich (default)**: Beautiful formatted output with colors and panels +```bash +tacomail create +# ╭────────────── ✨ Success ──────────────╮ +# │ Generated Email: │ +# │ x7k9m2@tacomail.de │ +# ╰────────────────────────────────────────╯ +``` + +**Plain**: Simple key=value format for shell scripting +```bash +tacomail --output plain create +# email=x7k9m2@tacomail.de + +tacomail -o plain list user@tacomail.de +# id123 sender@example.com Subject Line 2026-01-15 10:30 +``` + +**JSON**: Machine-readable JSON output +```bash +tacomail --output json create +# {"email": "x7k9m2@tacomail.de"} + +tacomail -o json list user@tacomail.de +# [{"id": "id123", "from": "sender@example.com", "subject": "Subject Line", "date": "2026-01-15 10:30"}] +``` + ### Help for Specific Commands Each command has its own help: @@ -149,9 +181,8 @@ tacomail wait x7k9m2@tacomail.de ### Complete Workflow Example ```bash -# Generate email and create session -EMAIL=$(tacomail create | grep -oP 'Generated Email:' | cut -d' ' -f2) -tacomail create-session $EMAIL +# Generate email and create session (using plain output for easy parsing) +EMAIL=$(tacomail -o plain new | grep '^email=' | cut -d'=' -f2) # Monitor inbox for incoming emails tacomail wait $EMAIL --timeout 60 @@ -182,8 +213,12 @@ tacomail wait **Workflow 2: Automated script** ```bash #!/bin/bash -# Get email and session (using the short alias) -EMAIL=$(tacomail new 2>&1 | grep -oP '\S+@\S+') +# Get email and session using plain output (easy to parse) +EMAIL=$(tacomail -o plain new | grep '^email=' | cut -d'=' -f2) +echo "Created: $EMAIL" + +# Or using JSON output with jq +EMAIL=$(tacomail -o json new | jq -r '.email') echo "Created: $EMAIL" # Monitor for emails (timeout 60s) diff --git a/src/tacomail/cli.py b/src/tacomail/cli.py index ac5349d..2a1d27e 100644 --- a/src/tacomail/cli.py +++ b/src/tacomail/cli.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Tacomail CLI - Command-line interface for Tacomail disposable email service.""" -from typing import Optional +from typing import Optional, Any, Callable +from enum import Enum import re +import json from datetime import datetime import typer @@ -16,6 +18,14 @@ Email, ) + +class OutputFormat(str, Enum): + """Output format options for CLI commands.""" + RICH = "rich" + PLAIN = "plain" + JSON = "json" + + app = typer.Typer( name="tacomail", help="Tacomail CLI - Disposable email service command-line interface", @@ -24,8 +34,33 @@ console = Console() -# Global option for async mode +# Global options use_async = False +output_format: OutputFormat = OutputFormat.RICH + + +def output_data(data: dict[str, Any] | list[Any], rich_callback: Callable[[], None]) -> None: + """Output data in the configured format. + + Args: + data: The data to output (dict for single items, list for multiple items) + rich_callback: A callback function to render rich output (only called for RICH format) + """ + if output_format == OutputFormat.JSON: + print(json.dumps(data, default=str)) + elif output_format == OutputFormat.PLAIN: + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + # For list of dicts, output tab-separated values + print("\t".join(str(v) for v in item.values())) + else: + print(item) + else: + for key, value in data.items(): + print(f"{key}={value}") + else: + rich_callback() def get_client(): @@ -61,11 +96,14 @@ def create( else: email_address = client.get_random_address() - console.print(Panel( - f"[bold green]Generated Email:[/bold green]\n{email_address}", - title="✨ Success", - border_style="green" - )) + def rich_output(): + console.print(Panel( + f"[bold green]Generated Email:[/bold green]\n{email_address}", + title="✨ Success", + border_style="green" + )) + + output_data({"email": email_address}, rich_output) except Exception as e: console.print(f"[red]Error creating email:[/red] {e}") raise typer.Exit(1) @@ -82,9 +120,12 @@ def list_domains() -> None: try: domains = client.get_domains() - console.print(f"\n[bold]Available Domains:[/bold] ({len(domains)})") - for domain in domains: - console.print(f" • {domain}") + def rich_output(): + console.print(f"\n[bold]Available Domains:[/bold] ({len(domains)})") + for domain in domains: + console.print(f" • {domain}") + + output_data(domains, rich_output) except Exception as e: console.print(f"[red]Error fetching domains:[/red] {e}") raise typer.Exit(1) @@ -152,6 +193,33 @@ def _create_with_session_impl( """ client = get_client() + def display_results(email_address: str, session, expires_str: str) -> None: + """Display the results using the configured output format.""" + data = { + "email": email_address, + "username": session.username, + "domain": session.domain, + "expires": expires_str, + } + + def rich_output(): + console.print(Panel( + f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" + f"[bold green]Session Created[/bold green]\n\n" + f"[bold]Expires:[/bold] {expires_str}\n" + f"[bold]Username:[/bold] {session.username}\n" + f"[bold]Domain:[/bold] {session.domain}\n\n" + f"[dim]You can now receive emails at this address![/dim]", + title="✨ Email & Session Ready", + border_style="green" + )) + + console.print("\n[bold cyan]Next steps:[/bold cyan]") + console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) + console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + + output_data(data, rich_output) + try: # Handle async mode if use_async: @@ -182,21 +250,7 @@ async def async_operation(): expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - # Display results - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) - - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + display_results(email_address, session, expires_str) asyncio.run(async_operation()) else: @@ -223,21 +277,7 @@ async def async_operation(): expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - # Display results - console.print(Panel( - f"[bold green]Email Address:[/bold green]\n{email_address}\n\n" - f"[bold green]Session Created[/bold green]\n\n" - f"[bold]Expires:[/bold] {expires_str}\n" - f"[bold]Username:[/bold] {session.username}\n" - f"[bold]Domain:[/bold] {session.domain}\n\n" - f"[dim]You can now receive emails at this address![/dim]", - title="✨ Email & Session Ready", - border_style="green" - )) - - console.print("\n[bold cyan]Next steps:[/bold cyan]") - console.print(" • Monitor inbox: [green]tacomail list {}[/green]".format(email_address)) - console.print(" • Wait for email: [green]tacomail wait {}[/green]".format(email_address)) + display_results(email_address, session, expires_str) except Exception as e: console.print(f"[red]Error creating email and session:[/red] {e}") @@ -268,15 +308,25 @@ def create_session( expires_dt = datetime.fromtimestamp(session.expires / 1000) expires_str = expires_dt.strftime("%Y-%m-%d %H:%M:%S") - console.print(Panel( - f"[bold green]Session Created[/bold green]\n\n" - f"Email: {email}\n" - f"Expires: {expires_str}\n" - f"Username: {session.username}\n" - f"Domain: {session.domain}", - title="🔐 Session", - border_style="green" - )) + data = { + "email": email, + "username": session.username, + "domain": session.domain, + "expires": expires_str, + } + + def rich_output(): + console.print(Panel( + f"[bold green]Session Created[/bold green]\n\n" + f"Email: {email}\n" + f"Expires: {expires_str}\n" + f"Username: {session.username}\n" + f"Domain: {session.domain}", + title="🔐 Session", + border_style="green" + )) + + output_data(data, rich_output) except Exception as e: console.print(f"[red]Error creating session:[/red] {e}") raise typer.Exit(1) @@ -302,7 +352,10 @@ def delete_session( client.delete_session(username, domain) - console.print(f"[green]✓ Session deleted for {email}[/green]") + def rich_output(): + console.print(f"[green]✓ Session deleted for {email}[/green]") + + output_data({"deleted": email}, rich_output) except Exception as e: console.print(f"[red]Error deleting session:[/red] {e}") raise typer.Exit(1) @@ -327,24 +380,42 @@ def list_inbox( emails = client.get_inbox(email, limit=limit) if not emails: + if output_format != OutputFormat.RICH: + # For non-rich formats, output empty list + output_data([], lambda: None) + return console.print(f"[yellow]No emails found for {email}[/yellow]") return - table = Table(title=f"Inbox for {email}") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("From", style="green") - table.add_column("Subject", style="white") - table.add_column("Date", style="blue") - - for email_obj in emails: - from_addr = f"{email_obj.from_.name} <{email_obj.from_.address}>" - subject = email_obj.subject[:50] + "..." if len(email_obj.subject) > 50 else email_obj.subject - date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") - - table.add_row(email_obj.id, from_addr, subject, date_str) - - console.print(table) - console.print(f"\n[dim]Showing {len(emails)} email(s)[/dim]") + # Prepare data for output + emails_data = [ + { + "id": email_obj.id, + "from": email_obj.from_.address, + "subject": email_obj.subject, + "date": email_obj.date.strftime("%Y-%m-%d %H:%M"), + } + for email_obj in emails + ] + + def rich_output(): + table = Table(title=f"Inbox for {email}") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("From", style="green") + table.add_column("Subject", style="white") + table.add_column("Date", style="blue") + + for email_obj in emails: + from_addr = f"{email_obj.from_.name} <{email_obj.from_.address}>" + subject = email_obj.subject[:50] + "..." if len(email_obj.subject) > 50 else email_obj.subject + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M") + + table.add_row(email_obj.id, from_addr, subject, date_str) + + console.print(table) + console.print(f"\n[dim]Showing {len(emails)} email(s)[/dim]") + + output_data(emails_data, rich_output) except Exception as e: console.print(f"[red]Error listing inbox:[/red] {e}") raise typer.Exit(1) @@ -369,7 +440,21 @@ def get_email( to_addr = f"{email_obj.to.name} <{email_obj.to.address}>" date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") - content = f""" + data = { + "id": email_obj.id, + "from": email_obj.from_.address, + "from_name": email_obj.from_.name, + "to": email_obj.to.address, + "to_name": email_obj.to.name, + "subject": email_obj.subject, + "date": date_str, + "attachments": len(email_obj.attachments), + "attachment_files": [att.fileName for att in email_obj.attachments], + "body": email_obj.body.text or "", + } + + def rich_output(): + content = f""" [bold]From:[/bold] {from_addr} [bold]To:[/bold] {to_addr} [bold]Subject:[/bold] {email_obj.subject} @@ -379,18 +464,20 @@ def get_email( [bold]Attachments:[/bold] {len(email_obj.attachments)} file(s) """ - if email_obj.attachments: - for att in email_obj.attachments: - status = "✓" if att.present else "✗" - content += f" {status} {att.fileName} (ID: {att.id})\n" + if email_obj.attachments: + for att in email_obj.attachments: + status = "✓" if att.present else "✗" + content += f" {status} {att.fileName} (ID: {att.id})\n" - content += "\n[bold]Body:[/bold]\n" - if email_obj.body.text: - content += f"\n{email_obj.body.text}\n" - else: - content += "[dim](No text body)[/dim]\n" + content += "\n[bold]Body:[/bold]\n" + if email_obj.body.text: + content += f"\n{email_obj.body.text}\n" + else: + content += "[dim](No text body)[/dim]\n" + + console.print(Panel(content.strip(), title="📧 Email", border_style="blue")) - console.print(Panel(content.strip(), title="📧 Email", border_style="blue")) + output_data(data, rich_output) except Exception as e: console.print(f"[red]Error getting email:[/red] {e}") raise typer.Exit(1) @@ -409,7 +496,11 @@ def delete_email( try: client.delete_email(email, mail_id) - console.print(f"[green]✓ Email {mail_id} deleted[/green]") + + def rich_output(): + console.print(f"[green]✓ Email {mail_id} deleted[/green]") + + output_data({"deleted": mail_id}, rich_output) except Exception as e: console.print(f"[red]Error deleting email:[/red] {e}") raise typer.Exit(1) @@ -429,11 +520,15 @@ def clear_inbox( client = get_client() try: - if not confirm: + if not confirm and output_format == OutputFormat.RICH: typer.confirm(f"Delete all emails from {email}?", abort=True) client.delete_inbox(email) - console.print(f"[green]✓ Inbox cleared for {email}[/green]") + + def rich_output(): + console.print(f"[green]✓ Inbox cleared for {email}[/green]") + + output_data({"cleared": email}, rich_output) except Exception as e: console.print(f"[red]Error clearing inbox:[/red] {e}") raise typer.Exit(1) @@ -453,7 +548,7 @@ def wait( None, "--filter", "-f", help="Filter by subject or sender (regex pattern)" ), print_body: bool = typer.Option( - False, "--print-body", "-p", help="Also print email body" + False, "--print-body", "-b", help="Also print email body" ), ) -> None: """Wait for a new email to arrive. @@ -464,7 +559,8 @@ def wait( client = get_client() try: - console.print(f"[dim]Waiting for email to {email}... (timeout: {timeout}s)[/dim]") + if output_format == OutputFormat.RICH: + console.print(f"[dim]Waiting for email to {email}... (timeout: {timeout}s)[/dim]") if filter_pattern: # Create filter function @@ -491,20 +587,39 @@ def filter_fn(email_obj: Email) -> bool: ) if email_obj: - console.print("\n[green]✓ Email received![/green]") - console.print(f" From: {email_obj.from_.name} <{email_obj.from_.address}>") - console.print(f" Subject: {email_obj.subject}") - - # Print email body if requested + date_str = email_obj.date.strftime("%Y-%m-%d %H:%M:%S") + data = { + "id": email_obj.id, + "from": email_obj.from_.address, + "from_name": email_obj.from_.name, + "subject": email_obj.subject, + "date": date_str, + } if print_body: - if email_obj.body.text: - console.print("\n[bold]Email Body:[/bold]") - console.print(f"{email_obj.body.text}") - else: - console.print("\n[dim]No text body available[/dim]") + data["body"] = email_obj.body.text or "" + + def rich_output(): + console.print("\n[green]✓ Email received![/green]") + console.print(f" From: {email_obj.from_.name} <{email_obj.from_.address}>") + console.print(f" Subject: {email_obj.subject}") + + # Print email body if requested + if print_body: + if email_obj.body.text: + console.print("\n[bold]Email Body:[/bold]") + console.print(f"{email_obj.body.text}") + else: + console.print("\n[dim]No text body available[/dim]") + + output_data(data, rich_output) else: - console.print("\n[yellow]⏱ Timeout: No email received[/yellow]") + def rich_output(): + console.print("\n[yellow]⏱ Timeout: No email received[/yellow]") + + output_data({"timeout": True}, rich_output) raise typer.Exit(1) + except typer.Exit: + raise except Exception as e: console.print(f"[red]Error waiting for email:[/red] {e}") raise typer.Exit(1) @@ -518,10 +633,14 @@ def main( verbose: bool = typer.Option( False, "--verbose", "-v", help="Enable verbose output" ), + output: OutputFormat = typer.Option( + OutputFormat.RICH, "--output", "-o", help="Output format (rich, plain, json)" + ), ) -> None: """Tacomail CLI - Disposable email service command-line interface.""" - global use_async + global use_async, output_format use_async = async_mode + output_format = output if verbose: import logging diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5ba6b9b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,479 @@ +"""Tests for the Tacomail CLI output format functionality.""" + +import json +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +from typer.testing import CliRunner + +from tacomail.cli import app +from tacomail import Email, Session + + +runner = CliRunner() + + +@pytest.fixture +def mock_email(): + """Create a mock Email object for testing.""" + email = MagicMock(spec=Email) + email.id = "test-email-id-123" + email.subject = "Test Subject" + email.date = datetime(2024, 1, 15, 10, 30, 0) + email.from_ = MagicMock() + email.from_.name = "Sender Name" + email.from_.address = "sender@example.com" + email.to = MagicMock() + email.to.name = "Recipient Name" + email.to.address = "recipient@example.com" + email.body = MagicMock() + email.body.text = "This is the email body." + email.attachments = [] + return email + + +@pytest.fixture +def mock_session(): + """Create a mock Session object for testing.""" + session = MagicMock(spec=Session) + session.username = "testuser" + session.domain = "tacomail.de" + session.expires = 1705320600000 # Some future timestamp in milliseconds + return session + + +class TestCreatePlainOutput: + """Tests for the 'create' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_plain_output(self, mock_get_client): + """Test that create command outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "create"]) + + assert result.exit_code == 0 + assert "email=random123@tacomail.de" in result.stdout + # Should not contain Rich formatting + assert "✨" not in result.stdout + assert "[bold" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_create_with_domain_plain_output(self, mock_get_client): + """Test create command with domain option in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_username.return_value = "randomuser" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "create", "-d", "tacomail.de"]) + + assert result.exit_code == 0 + assert "email=randomuser@tacomail.de" in result.stdout + + +class TestCreateJsonOutput: + """Tests for the 'create' command JSON output.""" + + @patch("tacomail.cli.get_client") + def test_create_json_output(self, mock_get_client): + """Test that create command outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "random123@tacomail.de" + + +class TestListDomainsPlainOutput: + """Tests for the 'list-domains' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_list_domains_plain_output(self, mock_get_client): + """Test that list-domains outputs one domain per line in plain mode.""" + mock_client = MagicMock() + mock_client.get_domains.return_value = ["tacomail.de", "example.com", "test.org"] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "list-domains"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert lines == ["tacomail.de", "example.com", "test.org"] + # Should not contain Rich formatting + assert "•" not in result.stdout + assert "[bold" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_list_domains_json_output(self, mock_get_client): + """Test that list-domains outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_domains.return_value = ["tacomail.de", "example.com", "test.org"] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "list-domains"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data == ["tacomail.de", "example.com", "test.org"] + + +class TestCreateSessionPlainOutput: + """Tests for the 'create-session' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_session_plain_output(self, mock_get_client, mock_session): + """Test that create-session outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "create-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=testuser@tacomail.de" in lines + assert "username=testuser" in lines + assert "domain=tacomail.de" in lines + assert any(line.startswith("expires=") for line in lines) + # Should not contain Rich formatting + assert "🔐" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_create_session_json_output(self, mock_get_client, mock_session): + """Test that create-session outputs JSON format.""" + mock_client = MagicMock() + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "testuser@tacomail.de" + assert data["username"] == "testuser" + assert data["domain"] == "tacomail.de" + assert "expires" in data + + +class TestDeleteSessionPlainOutput: + """Tests for the 'delete-session' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_delete_session_plain_output(self, mock_get_client): + """Test that delete-session outputs deleted=email in plain mode.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "delete-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "deleted=testuser@tacomail.de" + # Should not contain Rich formatting + assert "✓" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_delete_session_json_output(self, mock_get_client): + """Test that delete-session outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "delete-session", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["deleted"] == "testuser@tacomail.de" + + +class TestListInboxPlainOutput: + """Tests for the 'list' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_list_inbox_plain_output(self, mock_get_client, mock_email): + """Test that list outputs tab-separated values in plain mode.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [mock_email] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + # Output should be tab-separated: id, from_address, subject, date + line = result.stdout.strip() + parts = line.split("\t") + assert len(parts) == 4 + assert parts[0] == "test-email-id-123" + assert parts[1] == "sender@example.com" + assert parts[2] == "Test Subject" + + @patch("tacomail.cli.get_client") + def test_list_inbox_empty_plain_output(self, mock_get_client): + """Test that empty inbox produces no output in plain mode.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "" + + @patch("tacomail.cli.get_client") + def test_list_inbox_json_output(self, mock_get_client, mock_email): + """Test that list outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_inbox.return_value = [mock_email] + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "list", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["id"] == "test-email-id-123" + assert data[0]["from"] == "sender@example.com" + + +class TestGetEmailPlainOutput: + """Tests for the 'get' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_get_email_plain_output(self, mock_get_client, mock_email): + """Test that get outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.get_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "get", "testuser@tacomail.de", "email-id"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "id=test-email-id-123" in lines + assert "from=sender@example.com" in lines + assert "from_name=Sender Name" in lines + assert "to=recipient@example.com" in lines + assert "to_name=Recipient Name" in lines + assert "subject=Test Subject" in lines + assert "body=This is the email body." in lines + # Should not contain Rich formatting + assert "📧" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_get_email_json_output(self, mock_get_client, mock_email): + """Test that get outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "get", "testuser@tacomail.de", "email-id"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["id"] == "test-email-id-123" + assert data["from"] == "sender@example.com" + assert data["subject"] == "Test Subject" + assert data["body"] == "This is the email body." + + +class TestDeleteEmailPlainOutput: + """Tests for the 'delete' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_delete_email_plain_output(self, mock_get_client): + """Test that delete outputs deleted=id in plain mode.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "delete", "testuser@tacomail.de", "email-id-123"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "deleted=email-id-123" + + @patch("tacomail.cli.get_client") + def test_delete_email_json_output(self, mock_get_client): + """Test that delete outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "delete", "testuser@tacomail.de", "email-id-123"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["deleted"] == "email-id-123" + + +class TestClearInboxPlainOutput: + """Tests for the 'clear' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_clear_inbox_plain_output(self, mock_get_client): + """Test that clear outputs cleared=email in plain mode (no confirmation needed).""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "clear", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "cleared=testuser@tacomail.de" + + @patch("tacomail.cli.get_client") + def test_clear_inbox_json_output(self, mock_get_client): + """Test that clear outputs JSON format.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "clear", "testuser@tacomail.de"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["cleared"] == "testuser@tacomail.de" + + +class TestWaitPlainOutput: + """Tests for the 'wait' command plain output.""" + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_success(self, mock_get_client, mock_email): + """Test that wait outputs key=value format in plain mode when email received.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "id=test-email-id-123" in lines + assert "from=sender@example.com" in lines + assert "subject=Test Subject" in lines + # Should not have the waiting message + assert "Waiting for email" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_timeout(self, mock_get_client): + """Test that wait outputs timeout=True in plain mode when no email.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = None + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 1 + assert "timeout=True" in result.stdout + + @patch("tacomail.cli.get_client") + def test_wait_plain_output_with_body(self, mock_get_client, mock_email): + """Test that wait with --print-body includes body in plain mode.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "wait", "testuser@tacomail.de", "-t", "1", "--print-body"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "body=This is the email body." in lines + + @patch("tacomail.cli.get_client") + def test_wait_json_output_success(self, mock_get_client, mock_email): + """Test that wait outputs JSON format when email received.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = mock_email + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["id"] == "test-email-id-123" + assert data["from"] == "sender@example.com" + assert data["subject"] == "Test Subject" + + @patch("tacomail.cli.get_client") + def test_wait_json_output_timeout(self, mock_get_client): + """Test that wait outputs JSON format on timeout.""" + mock_client = MagicMock() + mock_client.wait_for_email.return_value = None + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "wait", "testuser@tacomail.de", "-t", "1"]) + + assert result.exit_code == 1 + data = json.loads(result.stdout.strip()) + assert data["timeout"] is True + + +class TestCreateWithSessionPlainOutput: + """Tests for the 'create-with-session' and 'new' commands plain output.""" + + @patch("tacomail.cli.get_client") + def test_create_with_session_plain_output(self, mock_get_client, mock_session): + """Test that create-with-session outputs key=value format in plain mode.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "create-with-session"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=random123@tacomail.de" in lines + assert "username=testuser" in lines + assert "domain=tacomail.de" in lines + assert any(line.startswith("expires=") for line in lines) + # Should not contain Rich formatting or next steps + assert "✨" not in result.stdout + assert "Next steps" not in result.stdout + + @patch("tacomail.cli.get_client") + def test_new_command_plain_output(self, mock_get_client, mock_session): + """Test that 'new' command (alias) also works with plain output.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "plain", "new"]) + + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert "email=random123@tacomail.de" in lines + + @patch("tacomail.cli.get_client") + def test_create_with_session_json_output(self, mock_get_client, mock_session): + """Test that create-with-session outputs JSON format.""" + mock_client = MagicMock() + mock_client.get_random_address.return_value = "random123@tacomail.de" + mock_client.create_session.return_value = mock_session + mock_get_client.return_value = mock_client + + result = runner.invoke(app, ["--output", "json", "create-with-session"]) + + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["email"] == "random123@tacomail.de" + assert data["username"] == "testuser" + assert data["domain"] == "tacomail.de" + assert "expires" in data + + +class TestOutputOptionHelp: + """Test that the --output option is properly documented.""" + + def test_output_option_in_help(self): + """Test that --output option appears in CLI help.""" + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + # Output may contain ANSI escape codes, so search for keywords + assert "output" in result.stdout.lower() + assert "json" in result.stdout.lower() + assert "plain" in result.stdout.lower() + assert "rich" in result.stdout.lower()