From 3f48ef8e1176f8cefe9800d858d9a25e349cd552 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 30 Apr 2025 10:03:15 -0400 Subject: [PATCH] basic email tui working --- email_viewer.tcss | 15 +++ fetch_outlook.py | 34 ++++- run_himalaya.sh | 140 ++++++++++++++++++++ tui.py | 18 +++ tui_email_viewer.py | 303 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 email_viewer.tcss create mode 100755 run_himalaya.sh create mode 100644 tui.py create mode 100644 tui_email_viewer.py diff --git a/email_viewer.tcss b/email_viewer.tcss new file mode 100644 index 0000000..b675b1a --- /dev/null +++ b/email_viewer.tcss @@ -0,0 +1,15 @@ +/* Basic stylesheet for the Textual Email Viewer App */ + +Label#task_prompt { + padding: 1; + color: rgb(128,128,128); +} + +Label#task_prompt_label { + padding: 1; + color: rgb(255, 216, 102); +} + +Label#message_label { + padding: 1; +} diff --git a/fetch_outlook.py b/fetch_outlook.py index 5d21bfd..4a2c7e2 100644 --- a/fetch_outlook.py +++ b/fetch_outlook.py @@ -31,7 +31,6 @@ def save_sync_timestamp(): with open(sync_timestamp_file, 'w') as f: json.dump({'last_sync': time.time()}, f) -# Function to synchronize maildir with the server def synchronize_maildir(maildir_path, headers): last_sync = load_last_sync_timestamp() current_time = time.time() @@ -67,6 +66,37 @@ def synchronize_maildir(maildir_path, headers): if response.status_code != 204: # 204 No Content indicates success print(f"Failed to move message to trash: {message_id}, {response.status_code}, {response.text}") + # Find messages moved to ".Archives/**/*" and move them to the "Archive" folder on the server + archive_dir = os.path.join(maildir_path, '.Archives') + archive_files = glob.glob(os.path.join(archive_dir, '**', '*.eml'), recursive=True) + + # Fetch the list of folders to find the "Archive" folder ID + print("Fetching server folders to locate 'Archive' folder...") + folder_response = requests.get('https://graph.microsoft.com/v1.0/me/mailFolders', headers=headers) + if folder_response.status_code != 200: + raise Exception(f"Failed to fetch mail folders: {folder_response.status_code}, {folder_response.text}") + + folders = folder_response.json().get('value', []) + archive_folder_id = None + for folder in folders: + if folder.get('displayName', '').lower() == 'archive': + archive_folder_id = folder.get('id') + break + + if not archive_folder_id: + raise Exception("No folder named 'Archive' found on the server.") + + for filepath in archive_files: + message_id = os.path.basename(filepath).split('.')[0] # Extract the Message-ID from the filename + print(f"Moving message to 'Archive' folder: {message_id}") + response = requests.post( + f'https://graph.microsoft.com/v1.0/me/messages/{message_id}/move', + headers=headers, + json={'destinationId': archive_folder_id} + ) + if response.status_code != 201: # 201 Created indicates success + print(f"Failed to move message to 'Archive': {message_id}, {response.status_code}, {response.text}") + # Save the current sync timestamp save_sync_timestamp() @@ -115,7 +145,7 @@ def save_email_to_maildir(maildir_path, email_data, attachments_dir): if email_data.get('body', {}).get('contentType', '').lower() == 'html': markdown_converter = html2text.HTML2Text() markdown_converter.ignore_images = True - markdown_converter.ignore_links = False + markdown_converter.ignore_links = True body_markdown = markdown_converter.handle(body_html) else: body_markdown = body_html diff --git a/run_himalaya.sh b/run_himalaya.sh new file mode 100755 index 0000000..6cd2287 --- /dev/null +++ b/run_himalaya.sh @@ -0,0 +1,140 @@ +#!/bin/bash + +# Check if an argument is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Function to refresh the vim-himalaya buffer +refresh_vim_himalaya() { + nvim --server /tmp/nvim-server --remote-send "Himalaya" +} + +# Function to read a single character without waiting for Enter +read_char() { + stty -echo -icanon time 0 min 1 + char=$(dd bs=1 count=1 2>/dev/null) + stty echo icanon + echo "$char" +} + +# Function to safely run the himalaya command and handle failures +run_himalaya_message_read() { + himalaya message read "$1" | glow -p + if [ $? -ne 0 ]; then + echo "Failed to open message $1." + return 1 + fi + return 0 +} + +# Function to prompt the user for an action +prompt_action() { + echo "What would you like to do?" + + # Step 1: Ask if the user wants to create a task + echo -n "Would you like to create a task for this message? (y/n): " + create_task=$(read_char) + echo "$create_task" # Echo the character for feedback + if [[ "$create_task" == "y" || "$create_task" == "Y" ]]; then + read -p "Task title: " task_title + task add "Followup on email $1 - $task_title" --project "Email" --due "$(date -d '+1 week' +%Y-%m-%d)" --priority "P3" --tags "email" + echo "Task created for message $1." + fi + + # Step 2: Ask if the user wants to delete or archive the message + echo "d) Delete the message" + echo "a) Move the message to the archive folder" + echo "x) Skip delete/archive step" + echo -n "Enter your choice (d/a/x): " + archive_or_delete=$(read_char) + echo "$archive_or_delete" # Echo the character for feedback + + case $archive_or_delete in + d) + echo "Deleting message $1..." + himalaya message delete "$1" + refresh_vim_himalaya + ;; + a) + echo "Archiving message $1..." + himalaya message move Archives "$1" + refresh_vim_himalaya + ;; + *) + echo "Invalid choice. Skipping delete/archive step." + ;; + esac + + # Step 3: Ask if the user wants to open the next message or exit + echo -e "\n" + echo "n) Open the next message" + echo "p) Open the previous message" + echo "x) Exit" + echo -n "Enter your choice (o/x): " + next_or_exit=$(read_char) + echo "$next_or_exit" # Echo the character for feedback + + case $next_or_exit in + n) + # Try opening the next message, retrying up to 5 times if necessary + attempts=0 + success=false + while [ $attempts -lt 5 ]; do + next_id=$(( $1 + attempts + 1 )) + echo "Attempting to open next message: $next_id" + if run_himalaya_message_read "$next_id"; then + success=true + break + else + echo "Failed to open message $next_id. Retrying..." + attempts=$((attempts + 1)) + fi + done + + if [ "$success" = false ]; then + echo "Unable to open any messages after 5 attempts. Exiting." + exit 1 + fi + ;; + p) + # Try opening the previous message, retrying up to 5 times if necessary + attempts=0 + success=false + while [ $attempts -lt 5 ]; do + prev_id=$(( $1 - attempts - 1 )) + echo "Attempting to open previous message: $prev_id" + if $0 $prev_id; then + success=true + break + else + echo "Failed to open message $prev_id. Retrying..." + attempts=$((attempts + 1)) + fi + done + + if [ "$success" = false ]; then + echo "Unable to open any messages after 5 attempts. Exiting." + fi + ;; + x) + echo "Exiting." + exit 0 + ;; + *) + echo "Invalid choice. Exiting." + exit 1 + ;; + esac +} + +# Run the himalaya command with the provided message number +run_himalaya_message_read "$1" +if [ $? -ne 0 ]; then + echo "Error reading message $1. Exiting." + exit 1 +fi + +# Prompt the user for the next action +prompt_action "$1" diff --git a/tui.py b/tui.py new file mode 100644 index 0000000..dad631c --- /dev/null +++ b/tui.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Static, Label + +class MSALApp(App): + """A Textual app for MSAL authentication.""" + + CSS_PATH = "msal_app.tcss" # Optional: For styling + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header(show_clock=True) + yield Footer() + yield Static(Label("MSAL Authentication App"), id="main_content") + + +if __name__ == "__main__": + app = MSALApp() + app.run() diff --git a/tui_email_viewer.py b/tui_email_viewer.py new file mode 100644 index 0000000..74613f2 --- /dev/null +++ b/tui_email_viewer.py @@ -0,0 +1,303 @@ +import logging +from typing import Iterable +from textual import on +from textual.app import App, ComposeResult, SystemCommand +from textual.logging import TextualHandler +from textual.screen import Screen +from textual.widgets import Header, Footer, Static, Label, Input, Button +from textual.reactive import Reactive +from textual.binding import Binding +from textual.timer import Timer +from textual.containers import ScrollableContainer, Horizontal, Vertical, Grid +import subprocess + +logging.basicConfig( + level="NOTSET", + handlers=[TextualHandler()], +) + +class OpenMessageScreen(Screen[int]): + def compose(self) -> ComposeResult: + yield Horizontal( + Label("📨", id="message_label"), + Input(placeholder="Enter message ID (integer only)", type="integer", id="open_message_input"), + Button("Open", variant="primary", id="open_message_button") + ) + + @on(Input.Submitted) + def handle_message_id(self) -> None: + logging.info("Open message") + input_widget = self.query_one("#open_message_input", Input) + self.disabled = True + self.loading = True + message_id = int(input_widget.value) + self.dismiss(message_id) + + @on(Input._on_key) + def handle_close(self, event) -> None: + if (event.key == "escape" or event.key == "ctrl+c"): + self.dismiss() + + +class CreateTaskScreen(Screen[str]): + def compose(self) -> ComposeResult: + yield Vertical( + Label("$>", id="task_prompt"), + Label("task ", id="task_prompt_label"), + Input(placeholder="arguments", id="task_input") + ) + + @on(Input.Submitted) + def handle_task_args(self) -> None: + input_widget = self.query_one("#task_input", Input) + self.disabled = True + self.loading = True + task_args = input_widget.value + self.dismiss(task_args) + + @on(Input._on_key) + def handle_close(self, event) -> None: + if (event.key == "escape" or event.key == "ctrl+c"): + self.dismiss() + + +class EmailViewerApp(App): + """A Textual app for viewing and managing emails.""" + title = "Mail Reader" + CSS_PATH = "email_viewer.tcss" # Optional: For styling + + current_message_id: Reactive[int] = Reactive(1) + + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: + yield from super().get_system_commands(screen) + yield SystemCommand("Next Message", "Navigate to Next ID", self.action_next) + yield SystemCommand("Previous Message", "Navigate to Previous ID", self.action_previous) + yield SystemCommand("Delete Message", "Delete the current message", self.action_delete) + yield SystemCommand("Archive Message", "Archive the current message", self.action_archive) + yield SystemCommand("Open Message", "Open a specific message by ID", self.action_open) + yield SystemCommand("Create Task", "Create a task using the task CLI", self.action_create_task) + + BINDINGS = [ + Binding("n", "next", "Next message"), + Binding("p", "previous", "Previous message"), + Binding("d", "delete", "Delete message"), + Binding("a", "archive", "Archive message"), + Binding("o", "open", "Open message", show=False), + Binding("q", "quit", "Quit application"), + Binding("c", "create_task", "Create Task") + ] + + BINDINGS.extend([ + Binding("j", "scroll_down", "Scroll down"), + Binding("k", "scroll_up", "Scroll up"), + Binding("down", "scroll_down", "Scroll down"), + Binding("up", "scroll_up", "Scroll up"), + Binding("space", "scroll_page_down", "Scroll page down"), + Binding("b", "scroll_page_up", "Scroll page up") + ]) + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header(show_clock=True) + yield Footer(Label("[n] Next | [p] Previous | [d] Delete | [a] Archive | [o] Open | [q] Quit | [c] Create Task")) + yield ScrollableContainer(Static(Label("Email Viewer App"), id="main_content")) + + def on_mount(self) -> None: + """Called when the app is mounted.""" + self.alert_timer: Timer | None = None # Timer to throttle alerts + # Fetch the ID of the most recent message using the Himalaya CLI + try: + result = subprocess.run( + ["himalaya", "envelope", "list", "-o", "json"], + capture_output=True, + text=True + ) + if result.returncode == 0: + import json + envelopes = json.loads(result.stdout) + if envelopes: + self.current_message_id = int(envelopes[0]['id']) # Get the first envelope's ID + else: + self.query_one("#main_content", Static).update("Failed to fetch the most recent message ID.") + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + + self.show_message(self.current_message_id) + + def show_message(self, message_id: int) -> None: + self.query_one("#main_content", Static).loading = True + """Fetch and display the email message by ID.""" + try: + result = subprocess.run( + ["himalaya", "message", "read", str(message_id)], + capture_output=True, + text=True + ) + if result.returncode == 0: + # Render the email content as Markdown + from rich.markdown import Markdown + markdown_content = Markdown(result.stdout, justify=True ) + self.query_one("#main_content", Static).loading = False + self.query_one("#main_content", Static).update(markdown_content) + else: + self.query_one("#main_content", Static).update(f"Failed to fetch message {message_id}.") + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + + def show_status(self, message: str, severity: str = "information") -> None: + """Display a status message using the built-in notify function.""" + self.notify(message, title="Status", severity=severity, timeout=1, markup=True) + + def action_next(self) -> None: + """Show the next email message, iterating until a valid one is found or giving up after 100 attempts.""" + try: + result = subprocess.run( + ["nvim", "--server", " /tmp/nvim-server", " --remote-send", "':Himalaya'"], + capture_output=False, + text=True + ) + except Exception as e: + logging.warning(f"Error running nvim himalaya refresh command. Maybe the nvim server isn't started? {e}") + return + self.query_one("#main_content", Static).loading = True + attempts = 0 + while attempts < 100: + self.current_message_id += 1 + try: + result = subprocess.run( + ["himalaya", "message", "read", str(self.current_message_id)], + capture_output=True, + text=True + ) + if result.returncode == 0: + self.query_one("#main_content", Static).loading = False + self.show_message(self.current_message_id) + self.show_status(f"Showing next message: {self.current_message_id}") + return + else: + attempts += 1 + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + return + self.query_one("#main_content", Static).update("No more messages found after 100 attempts.") + + def action_previous(self) -> None: + """Show the previous email message, iterating until a valid one is found or giving up after 100 attempts.""" + self.show_status("Loading previous message...") + attempts = 0 + while attempts < 100 and self.current_message_id > 1: + self.current_message_id -= 1 + try: + result = subprocess.run( + ["himalaya", "message", "read", str(self.current_message_id)], + capture_output=True, + text=True + ) + if result.returncode == 0: + self.show_message(self.current_message_id) + self.show_status(f"Showing previous message: {self.current_message_id}") + return + else: + attempts += 1 + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + return + self.query_one("#main_content", Static).update("No more messages found after 100 attempts.") + + def action_delete(self) -> None: + """Delete the current email message.""" + self.show_status(f"Deleting message {self.current_message_id}...") + self.query_one("#main_content", Static).loading = True + try: + result = subprocess.run( + ["himalaya", "message", "delete", str(self.current_message_id)], + capture_output=True, + text=True + ) + if result.returncode == 0: + self.query_one("#main_content", Static).loading = False + self.query_one("#main_content", Static).update(f"Message {self.current_message_id} deleted.") + self.show_status(f"Message {self.current_message_id} deleted.") + self.action_next() # Automatically show the next message + else: + self.query_one("#main_content", Static).update(f"Failed to delete message {self.current_message_id}.") + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + + def action_archive(self) -> None: + """Archive the current email message.""" + self.show_status(f"Archiving message {self.current_message_id}...") + try: + result = subprocess.run( + ["himalaya", "message", "move", "Archives", str(self.current_message_id)], + capture_output=True, + text=True + ) + if result.returncode == 0: + self.query_one("#main_content", Static).update(f"Message {self.current_message_id} archived.") + self.show_status(f"Message {self.current_message_id} archived.") + self.action_next() # Automatically show the next message + else: + self.query_one("#main_content", Static).update(f"Failed to archive message {self.current_message_id}.") + except Exception as e: + self.query_one("#main_content", Static).update(f"Error: {e}") + + def action_open(self) -> None: + """Show the input modal for opening a specific message by ID.""" + def check_id(message_id: str) -> bool: + try: + int(message_id) + self.app.show_status(f"Opening message {message_id}...") + self.app.current_message_id = message_id + self.app.show_message(self.app.current_message_id) + except ValueError: + self.app.show_status("Invalid message ID. Please enter an integer.", severity="error") + return True + except ValueError: + return False + self.push_screen(OpenMessageScreen(), check_id) + + + def action_create_task(self) -> None: + """Show the input modal for creating a task.""" + def check_task(task_args: str) -> bool: + try: + result = subprocess.run( + ["task"] + task_args.split(), + capture_output=True, + text=True + ) + if result.returncode == 0: + self.show_status("Task created successfully.") + else: + self.show_status(f"Failed to create task: {result.stderr}") + except Exception as e: + self.show_status(f"Error: {e}", severity="error") + return True + return False + self.push_screen(CreateTaskScreen(), check_task) + + + def action_scroll_down(self) -> None: + """Scroll the main content down.""" + self.query_one("#main_content", Static).scroll_down() + + def action_scroll_up(self) -> None: + """Scroll the main content up.""" + self.query_one("#main_content", Static).scroll_up() + + def action_scroll_page_down(self) -> None: + """Scroll the main content down by a page.""" + self.query_one("#main_content", Static).scroll_page_down() + + def action_scroll_page_up(self) -> None: + """Scroll the main content up by a page.""" + self.query_one("#main_content", Static).scroll_page_up() + + def action_quit(self) -> None: + """Quit the application.""" + self.exit() + +if __name__ == "__main__": + app = EmailViewerApp() + app.run()