basic email tui working

This commit is contained in:
Tim Bendt
2025-04-30 10:03:15 -04:00
parent 59372b91ad
commit 3f48ef8e11
5 changed files with 508 additions and 2 deletions

15
email_viewer.tcss Normal file
View File

@@ -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;
}

View File

@@ -31,7 +31,6 @@ def save_sync_timestamp():
with open(sync_timestamp_file, 'w') as f: with open(sync_timestamp_file, 'w') as f:
json.dump({'last_sync': time.time()}, f) json.dump({'last_sync': time.time()}, f)
# Function to synchronize maildir with the server
def synchronize_maildir(maildir_path, headers): def synchronize_maildir(maildir_path, headers):
last_sync = load_last_sync_timestamp() last_sync = load_last_sync_timestamp()
current_time = time.time() current_time = time.time()
@@ -67,6 +66,37 @@ def synchronize_maildir(maildir_path, headers):
if response.status_code != 204: # 204 No Content indicates success 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}") 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 the current sync timestamp
save_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': if email_data.get('body', {}).get('contentType', '').lower() == 'html':
markdown_converter = html2text.HTML2Text() markdown_converter = html2text.HTML2Text()
markdown_converter.ignore_images = True markdown_converter.ignore_images = True
markdown_converter.ignore_links = False markdown_converter.ignore_links = True
body_markdown = markdown_converter.handle(body_html) body_markdown = markdown_converter.handle(body_html)
else: else:
body_markdown = body_html body_markdown = body_html

140
run_himalaya.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/bin/bash
# Check if an argument is provided
if [ -z "$1" ]; then
echo "Usage: $0 <message_number>"
exit 1
fi
# Function to refresh the vim-himalaya buffer
refresh_vim_himalaya() {
nvim --server /tmp/nvim-server --remote-send "Himalaya<CR>"
}
# 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"

18
tui.py Normal file
View File

@@ -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()

303
tui_email_viewer.py Normal file
View File

@@ -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<CR>'"],
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()