Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 111 additions & 5 deletions src/gmail_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def full(
max_messages: Optional[int] = typer.Option(None, help="Maximum number of messages to sync"),
force: bool = typer.Option(False, "--force", "-f", help="Start fresh sync, ignore any partial progress"),
workers: int = typer.Option(2, "--workers", "-w", help="Number of concurrent workers (1-10, default 2)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be synced without syncing"),
):
"""Perform a full sync of all Gmail messages.

Expand Down Expand Up @@ -166,10 +167,12 @@ def full(
try:
with RichToolkit(style=TaggedStyle(tag_width=12, theme=GMAIL_THEME)) as app:
app.print_title("Gmail CLI - Full Sync", tag="gmail-cli")
if dry_run:
app.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]", tag="mode")
app.print_line()

engine = SyncEngine(creds, app=app, max_workers=workers)
engine.full_sync(max_messages=max_messages, force=force)
engine.full_sync(max_messages=max_messages, force=force, dry_run=dry_run)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
Expand Down Expand Up @@ -430,20 +433,81 @@ def read(
message_id: str,
raw: bool = typer.Option(False, "--raw", help="Show raw headers and body"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
headers: bool = typer.Option(False, "--headers", help="Show all email headers"),
format: str = typer.Option("pretty", "--format", help="Output format: pretty, raw (RFC822)"),
):
"""Read a specific message by ID."""
"""Read a specific message by ID.

Examples:
gmail read <id> # Pretty formatted
gmail read <id> --headers # Show all headers
gmail read <id> --format raw # Get RFC822 format from API
gmail read <id> --json # JSON output
"""
from gmail_cli.db import get_session
from gmail_cli.db.queries import get_message, get_message_attachments
from gmail_cli.error_handler import handle_error
from gmail_cli.exceptions import MessageNotFoundError
from gmail_cli.validators import validate_message_id
from rich.panel import Panel
from rich.markdown import Markdown
import json

# Validate input
try:
validate_message_id(message_id)
except Exception as e:
handle_error(e, json_output=json_output)

session = get_session()
try:
message = get_message(session, message_id)
if not message:
console.print(f"[red]Error:[/red] Message {message_id} not found")
raise typer.Exit(1)
handle_error(
MessageNotFoundError(
f"Message {message_id} not found",
{"message_id": message_id}
),
json_output=json_output
)

# Handle --format raw (fetch RFC822 from Gmail API)
if format == "raw":
from gmail_cli.auth.manager import AuthManager
from gmail_cli.sync.client import GmailClient

auth_manager = AuthManager()
if not auth_manager.is_authenticated():
console.print("[red]Error:[/red] Not authenticated")
raise typer.Exit(1)

creds = auth_manager._load_credentials()
client = GmailClient(creds)

try:
raw_message = client.get_message_raw(message_id)
console.print(raw_message)
except Exception as e:
console.print(f"[red]Error:[/red] Failed to fetch raw message: {e}")
raise typer.Exit(1)
finally:
session.close()
return

# Handle --headers flag
if headers:
if message.raw_headers:
try:
headers_dict = json.loads(message.raw_headers)
console.print("\n[bold]Email Headers:[/bold]")
for key, value in headers_dict.items():
console.print(f" [cyan]{key}:[/cyan] {value}")
except json.JSONDecodeError:
console.print("[yellow]Headers are not in valid JSON format[/yellow]")
else:
console.print("[yellow]No headers available[/yellow]")
session.close()
return

if json_output:
# JSON output for agents
Expand Down Expand Up @@ -528,12 +592,20 @@ def read(
def thread(
thread_id: str,
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
tree: bool = typer.Option(False, "--tree", help="Show as tree structure"),
):
"""View all messages in a thread."""
"""View all messages in a thread.

Examples:
gmail thread <id> # Linear list
gmail thread <id> --tree # Tree structure
"""
from gmail_cli.db import get_session
from gmail_cli.db.queries import get_messages_by_thread
from gmail_cli.db.thread_tree import build_thread_tree
from rich.panel import Panel
from rich.text import Text
from rich.tree import Tree
import json

session = get_session()
Expand All @@ -543,6 +615,16 @@ def thread(
console.print(f"[red]Error:[/red] Thread {thread_id} not found")
raise typer.Exit(1)

if tree:
# Build and show tree structure
tree_data = build_thread_tree(messages)

if json_output:
console.print(json.dumps(tree_data, indent=2, ensure_ascii=False))
else:
_print_thread_tree(tree_data)
return

if json_output:
# JSON output for agents
result = [
Expand Down Expand Up @@ -589,6 +671,30 @@ def thread(
session.close()


def _print_thread_tree(tree_data: dict) -> None:
"""Print thread tree structure."""
from rich.tree import Tree

tree = Tree(f"[bold]Thread:[/bold] {tree_data['thread_id']} ({tree_data['message_count']} messages)")

for msg in tree_data["messages"]:
_add_message_to_tree(tree, msg)

console.print(tree)


def _add_message_to_tree(tree, msg: dict) -> None:
"""Recursively add message and children to tree."""
label = f"[bold]{msg['subject'] or '(no subject)'}[/bold]\n"
label += f"From: {msg['from']}\n"
label += f"Date: {msg['date']}"

branch = tree.add(label)

for child in msg.get("children", []):
_add_message_to_tree(branch, child)


# Attachments subcommand group
attachments_app = typer.Typer(
name="attachments",
Expand Down
85 changes: 85 additions & 0 deletions src/gmail_cli/db/thread_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Build thread tree structure from flat message list."""

import json
from typing import List, Dict, Optional
from gmail_cli.db.models import Message


def build_thread_tree(messages: List[Message]) -> Dict:
"""Build hierarchical thread tree from flat message list.

Uses In-Reply-To and References headers to determine parent-child
relationships. Falls back to date ordering if headers unavailable.

Args:
messages: Flat list of messages in a thread

Returns:
Tree structure with nested children
"""
if not messages:
return {}

# Sort by date (oldest first for tree building)
messages = sorted(messages, key=lambda m: m.date_utc or "")

# Build message lookup by ID
msg_map = {msg.id: _message_to_dict(msg) for msg in messages}

# Extract Message-ID and In-Reply-To headers
for msg in messages:
msg_dict = msg_map[msg.id]
if msg.raw_headers:
try:
headers = json.loads(msg.raw_headers)
msg_dict["message_id_header"] = headers.get("message-id")
msg_dict["in_reply_to"] = headers.get("in-reply-to")
msg_dict["references"] = headers.get("references", "").split() if headers.get("references") else []
except (json.JSONDecodeError, AttributeError):
pass

# Build parent-child relationships
root_messages = []
for msg_id, msg_dict in msg_map.items():
parent_id = _find_parent(msg_dict, msg_map)

if parent_id and parent_id in msg_map:
# Add as child to parent
if "children" not in msg_map[parent_id]:
msg_map[parent_id]["children"] = []
msg_map[parent_id]["children"].append(msg_dict)
else:
# Root message
root_messages.append(msg_dict)

return {
"thread_id": messages[0].thread_id,
"message_count": len(messages),
"messages": root_messages
}


def _message_to_dict(msg: Message) -> Dict:
"""Convert Message to dict for tree."""
return {
"id": msg.id,
"subject": msg.subject,
"from": msg.from_addr,
"date": msg.date_utc.isoformat() if msg.date_utc else None,
"snippet": msg.snippet,
"children": []
}


def _find_parent(msg_dict: Dict, msg_map: Dict) -> Optional[str]:
"""Find parent message ID using In-Reply-To header."""
in_reply_to = msg_dict.get("in_reply_to")
if not in_reply_to:
return None

# Search for message with matching Message-ID header
for msg_id, candidate in msg_map.items():
if candidate.get("message_id_header") == in_reply_to:
return msg_id

return None
63 changes: 63 additions & 0 deletions src/gmail_cli/error_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Centralized error handling for CLI commands."""

import json
from datetime import datetime
from typing import Optional
from rich.console import Console
from gmail_cli.exceptions import GmailCliException
import typer

console = Console()


def handle_error(
error: Exception,
json_output: bool = False,
exit_code: int = 1
) -> None:
"""Handle errors consistently across CLI commands.

Args:
error: Exception to handle
json_output: Whether to output JSON format
exit_code: Exit code to use
"""
if isinstance(error, GmailCliException):
if json_output:
_print_json_error(error)
else:
_print_pretty_error(error)
else:
# Unexpected error
if json_output:
_print_json_error(GmailCliException(
str(error),
{"type": type(error).__name__}
))
else:
console.print(f"[red]Error:[/red] {error}")

raise typer.Exit(exit_code)


def _print_json_error(error: GmailCliException) -> None:
"""Print structured JSON error."""
error_dict = {
"success": False,
"error": {
"code": error.code,
"message": error.message,
"details": error.details,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
}
console.print(json.dumps(error_dict, indent=2))


def _print_pretty_error(error: GmailCliException) -> None:
"""Print user-friendly error message."""
console.print(f"[red]Error:[/red] {error.message}")

if error.details:
for key, value in error.details.items():
console.print(f" {key}: {value}")
40 changes: 40 additions & 0 deletions src/gmail_cli/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,43 @@
class UserCancelled(Exception):
"""Raised when the user cancels an operation."""
pass


class GmailCliException(Exception):
"""Base exception with error code."""
code: str = "UNKNOWN_ERROR"

def __init__(self, message: str, details: dict = None):
super().__init__(message)
self.message = message
self.details = details or {}


class InvalidQueryError(GmailCliException):
"""Invalid search query syntax."""
code = "INVALID_QUERY"


class RateLimitError(GmailCliException):
"""Gmail API rate limit exceeded."""
code = "RATE_LIMIT"


class MessageNotFoundError(GmailCliException):
"""Message not found in database."""
code = "MESSAGE_NOT_FOUND"


class SyncError(GmailCliException):
"""Error during sync operation."""
code = "SYNC_ERROR"


class AuthenticationError(GmailCliException):
"""Authentication failed."""
code = "AUTH_ERROR"


class ValidationError(GmailCliException):
"""Input validation failed."""
code = "VALIDATION_ERROR"
Loading