From df4c49c3efa4f665294bfe0c7b11329c16c23920 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Tue, 15 Jul 2025 22:13:46 -0400 Subject: [PATCH] trying a simple shell script and fixing archives --- fetch_outlook.py | 202 -------------- run_himalaya.sh | 140 ---------- shell/email_processor.awk | 38 +++ shell/email_processor.py | 125 +++++++++ shell/run_himalaya.sh | 262 ++++++++++++++++++ shell/run_tests.sh | 41 +++ .../test_refactored.sh | 0 src/cli/sync.py | 224 ++++++++++++--- src/services/microsoft_graph/mail.py | 59 ++-- tests/fixtures/envelope_list_empty.txt | 0 tests/fixtures/envelope_list_normal.txt | 6 + tests/fixtures/envelope_list_single.txt | 3 + tests/fixtures/message_content_1.txt | 11 + tests/himalaya | 1 + tests/integration_tests.sh | 152 ++++++++++ tests/mock_himalaya.sh | 101 +++++++ tests/test_utils.sh | 132 +++++++++ tests/unit_tests.sh | 165 +++++++++++ 18 files changed, 1273 insertions(+), 389 deletions(-) delete mode 100644 fetch_outlook.py delete mode 100755 run_himalaya.sh create mode 100755 shell/email_processor.awk create mode 100755 shell/email_processor.py create mode 100755 shell/run_himalaya.sh create mode 100755 shell/run_tests.sh rename test_refactored.sh => shell/test_refactored.sh (100%) create mode 100644 tests/fixtures/envelope_list_empty.txt create mode 100644 tests/fixtures/envelope_list_normal.txt create mode 100644 tests/fixtures/envelope_list_single.txt create mode 100644 tests/fixtures/message_content_1.txt create mode 120000 tests/himalaya create mode 100755 tests/integration_tests.sh create mode 100755 tests/mock_himalaya.sh create mode 100644 tests/test_utils.sh create mode 100755 tests/unit_tests.sh diff --git a/fetch_outlook.py b/fetch_outlook.py deleted file mode 100644 index 32bdef8..0000000 --- a/fetch_outlook.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -Fetch and synchronize emails and calendar events from Microsoft Outlook (Graph API). -""" -import os -import argparse -import asyncio -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn - -# Import the refactored modules -from apis.microsoft_graph.auth import get_access_token -from apis.microsoft_graph.mail import fetch_mail_async, archive_mail_async, delete_mail_async, synchronize_maildir_async -from apis.microsoft_graph.calendar import fetch_calendar_events -from utils.calendar_utils import save_events_to_vdir, save_events_to_file -from utils.mail_utils.helpers import ensure_directory_exists - -# Add argument parsing for dry-run mode -arg_parser = argparse.ArgumentParser(description="Fetch and synchronize emails.") -arg_parser.add_argument("--dry-run", action="store_true", help="Run in dry-run mode without making changes.", default=False) -arg_parser.add_argument("--vdir", help="Output calendar events in vdir format to the specified directory (each event in its own file)", default=None) -arg_parser.add_argument("--icsfile", help="Output calendar events into this ics file path.", default=None) -arg_parser.add_argument("--org", help="Specify the organization name for the subfolder to store emails and calendar events", default="corteva") -arg_parser.add_argument("--days-back", type=int, help="Number of days to look back for calendar events", default=1) -arg_parser.add_argument("--days-forward", type=int, help="Number of days to look forward for calendar events", default=6) -arg_parser.add_argument("--continue-iteration", action="store_true", help="Enable interactive mode to continue fetching more date ranges", default=False) -arg_parser.add_argument("--download-attachments", action="store_true", help="Download email attachments", default=False) -args = arg_parser.parse_args() - -# Parse command line arguments -dry_run = args.dry_run -vdir_path = args.vdir -ics_path = args.icsfile -org_name = args.org -days_back = args.days_back -days_forward = args.days_forward -continue_iteration = args.continue_iteration -download_attachments = args.download_attachments - -async def fetch_calendar_async(headers, progress, task_id): - """ - Fetch calendar events and save them in the appropriate format. - - Args: - headers: Authentication headers for Microsoft Graph API - progress: Progress instance for updating progress bars - task_id: ID of the task in the progress bar - - Returns: - List of event dictionaries - - Raises: - Exception: If there's an error fetching or saving events - """ - from datetime import datetime, timedelta - - try: - # Use the utility function to fetch calendar events - progress.console.print("[cyan]Fetching events from Microsoft Graph API...[/cyan]") - events, total_events = await fetch_calendar_events( - headers=headers, - days_back=days_back, - days_forward=days_forward - ) - - progress.console.print(f"[cyan]Got {len(events)} events from API (reported total: {total_events})[/cyan]") - - # Update progress bar with total events - progress.update(task_id, total=total_events) - - # Save events to appropriate format - if not dry_run: - if vdir_path: - # Create org-specific directory within vdir path - org_vdir_path = os.path.join(vdir_path, org_name) - progress.console.print(f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]") - save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run) - progress.console.print(f"[green]Finished saving events to vdir: {org_vdir_path}[/green]") - elif ics_path: - # Save to a single ICS file in the output_ics directory - progress.console.print(f"[cyan]Saving events to ICS file: {ics_path}/events_latest.ics[/cyan]") - save_events_to_file(events, f"{ics_path}/events_latest.ics", progress, task_id, dry_run) - progress.console.print(f"[green]Finished saving events to ICS file[/green]") - else: - # No destination specified - progress.console.print("[yellow]Warning: No destination path (--vdir or --icsfile) specified for calendar events.[/yellow]") - else: - progress.console.print(f"[DRY-RUN] Would save {len(events)} events to {'vdir format' if vdir_path else 'single ICS file'}") - progress.update(task_id, advance=len(events)) - - # Interactive mode: Ask if the user wants to continue with the next date range - if continue_iteration: - # Move to the next date range - next_start_date = datetime.now() - timedelta(days=days_back) - next_end_date = next_start_date + timedelta(days=days_forward) - - progress.console.print(f"\nCurrent date range: {next_start_date.strftime('%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}") - - user_response = input("\nContinue to iterate? [y/N]: ").strip().lower() - - while user_response == 'y': - progress.console.print(f"\nFetching events for {next_start_date.strftime('%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}...") - - # Reset the progress bar for the new fetch - progress.update(task_id, completed=0, total=0) - - # Fetch events for the next date range - next_events, next_total_events = await fetch_calendar_events( - headers=headers, - days_back=0, - days_forward=days_forward, - start_date=next_start_date, - end_date=next_end_date - ) - - # Update progress bar with total events - progress.update(task_id, total=next_total_events) - - if not dry_run: - if vdir_path: - save_events_to_vdir(next_events, org_vdir_path, progress, task_id, dry_run) - else: - save_events_to_file(next_events, f'output_ics/outlook_events_{next_start_date.strftime("%Y%m%d")}.ics', - progress, task_id, dry_run) - else: - progress.console.print(f"[DRY-RUN] Would save {len(next_events)} events to {'vdir format' if vdir_path else 'output_ics/outlook_events_' + next_start_date.strftime("%Y%m%d") + '.ics'}") - progress.update(task_id, advance=len(next_events)) - - # Calculate the next date range - next_start_date = next_end_date - next_end_date = next_start_date + timedelta(days=days_forward) - - progress.console.print(f"\nNext date range would be: {next_start_date.strftime('%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}") - user_response = input("\nContinue to iterate? [y/N]: ").strip().lower() - - return events - except Exception as e: - progress.console.print(f"[red]Error fetching or saving calendar events: {str(e)}[/red]") - import traceback - progress.console.print(f"[red]{traceback.format_exc()}[/red]") - progress.update(task_id, completed=True) - return [] - -# Function to create Maildir structure -def create_maildir_structure(base_path): - """ - Create the standard Maildir directory structure. - - Args: - base_path (str): Base path for the Maildir. - - Returns: - None - """ - ensure_directory_exists(os.path.join(base_path, 'cur')) - ensure_directory_exists(os.path.join(base_path, 'new')) - ensure_directory_exists(os.path.join(base_path, 'tmp')) - ensure_directory_exists(os.path.join(base_path, '.Archives')) - ensure_directory_exists(os.path.join(base_path, '.Trash', 'cur')) - -async def main(): - """ - Main function to run the script. - - Returns: - None - """ - # Save emails to Maildir - maildir_path = os.getenv('MAILDIR_PATH', os.path.expanduser('~/Mail')) + f"/{org_name}" - attachments_dir = os.path.join(maildir_path, 'attachments') - ensure_directory_exists(attachments_dir) - create_maildir_structure(maildir_path) - - # Define scopes for Microsoft Graph API - scopes = ['https://graph.microsoft.com/Calendars.Read', 'https://graph.microsoft.com/Mail.ReadWrite'] - - # Authenticate and get access token - access_token, headers = get_access_token(scopes) - - # Set up the progress bars - progress = Progress( - SpinnerColumn(), - MofNCompleteColumn(), - *Progress.get_default_columns() - ) - - with progress: - task_fetch = progress.add_task("[green]Syncing Inbox...", total=0) - task_calendar = progress.add_task("[cyan]Fetching calendar...", total=0) - task_read = progress.add_task("[blue]Marking as read...", total=0) - task_archive = progress.add_task("[yellow]Archiving mail...", total=0) - task_delete = progress.add_task("[red]Deleting mail...", total=0) - - await asyncio.gather( - synchronize_maildir_async(maildir_path, headers, progress, task_read, dry_run), - archive_mail_async(maildir_path, headers, progress, task_archive, dry_run), - delete_mail_async(maildir_path, headers, progress, task_delete, dry_run), - fetch_mail_async(maildir_path, attachments_dir, headers, progress, task_fetch, dry_run, download_attachments), - fetch_calendar_async(headers, progress, task_calendar) - ) - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/run_himalaya.sh b/run_himalaya.sh deleted file mode 100755 index 6cd2287..0000000 --- a/run_himalaya.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/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/shell/email_processor.awk b/shell/email_processor.awk new file mode 100755 index 0000000..94496bf --- /dev/null +++ b/shell/email_processor.awk @@ -0,0 +1,38 @@ +#!/usr/bin/awk -f + +# Primary email processor using AWK +# Lightweight, portable text processing for cleaning up email content + +{ + # Remove URL defense wrappers step by step + gsub(/https:\/\/urldefense\.com\/v3\/__/, "") + gsub(/__[^[:space:]]*/, "") + + # Extract and shorten URLs to domains + # This processes all URLs in the line + while (match($0, /https?:\/\/[^\/[:space:]]+/)) { + url = substr($0, RSTART, RLENGTH) + # Extract domain (remove protocol) + domain = url + gsub(/^https?:\/\//, "", domain) + # Replace this URL with [domain] + sub(url, "[" domain "]", $0) + } + + # Remove any remaining URL paths after domain extraction + gsub(/\][^[:space:]]*/, "]") + + # Remove mailto links + gsub(/mailto:[^[:space:]]*/, "") + + # Clean up email headers - make them bold + if (/^From:/) { gsub(/^From:[[:space:]]*/, "**From:** ") } + if (/^To:/) { gsub(/^To:[[:space:]]*/, "**To:** ") } + if (/^Subject:/) { gsub(/^Subject:[[:space:]]*/, "**Subject:** ") } + if (/^Date:/) { gsub(/^Date:[[:space:]]*/, "**Date:** ") } + + # Skip empty lines + if (/^[[:space:]]*$/) next + + print +} \ No newline at end of file diff --git a/shell/email_processor.py b/shell/email_processor.py new file mode 100755 index 0000000..94591ba --- /dev/null +++ b/shell/email_processor.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Email content processor for run_himalaya.sh +Cleans up email content for better readability +""" + +import sys +import re +from urllib.parse import urlparse + + +def extract_domain(url): + """Extract domain from URL""" + try: + parsed = urlparse(url if url.startswith("http") else "http://" + url) + return parsed.netloc.replace("www.", "") + except: + return None + + +def is_important_url(url, domain): + """Determine if URL should be shown in full""" + important_domains = [ + "github.com", + "gitlab.com", + "jira.", + "confluence.", + "docs.google.com", + "drive.google.com", + "sharepoint.com", + "slack.com", + "teams.microsoft.com", + "zoom.us", + ] + + # Show short URLs in full + if len(url) < 60: + return True + + # Show URLs with important domains in shortened form + if domain and any(imp in domain for imp in important_domains): + return True + + return False + + +def clean_url_defenses(text): + """Remove URL defense wrappers""" + # Remove Proofpoint URL defense + text = re.sub(r"https://urldefense\.com/v3/__([^;]+)__;[^$]*\$", r"\1", text) + + # Remove other common URL wrappers + text = re.sub( + r"https://[^/]*phishalarm[^/]*/[^/]*/[^$]*\$", "[Security Link Removed]", text + ) + + return text + + +def process_email_content(content): + """Process email content for better readability""" + + # Remove URL defense wrappers first + content = clean_url_defenses(content) + + # Clean up common email artifacts first + content = re.sub( + r"ZjQcmQRYFpfpt.*?ZjQcmQRYFpfpt\w+End", + "[Security Banner Removed]", + content, + flags=re.DOTALL, + ) + + # Clean up mailto links that clutter the display + content = re.sub(r"mailto:[^\s>]+", "", content) + + # Pattern to match URLs (more conservative) + url_pattern = r'https?://[^\s<>"{}|\\^`\[\]\(\)]+[^\s<>"{}|\\^`\[\]\(\).,;:!?]' + + def replace_url(match): + url = match.group(0) + domain = extract_domain(url) + + if is_important_url(url, domain): + if domain and len(url) > 60: + return f"[{domain}]" + else: + return url + else: + if domain: + return f"[{domain}]" + else: + return "[Link]" + + # Replace URLs + content = re.sub(url_pattern, replace_url, content) + + # Clean up email headers formatting + content = re.sub( + r"^(From|To|Subject|Date):\s*(.+?)$", r"**\1:** \2", content, flags=re.MULTILINE + ) + + # Clean up angle brackets around email addresses that are left over + content = re.sub(r"<[^>]*@[^>]*>", "", content) + + # Remove excessive whitespace but preserve paragraph breaks + content = re.sub(r"\n\s*\n\s*\n+", "\n\n", content) + content = re.sub(r"[ \t]+", " ", content) + + # Remove lines that are just whitespace + content = re.sub(r"^\s*$\n", "", content, flags=re.MULTILINE) + + # Clean up repeated domain references on same line + content = re.sub(r"\[([^\]]+)\].*?\[\1\]", r"[\1]", content) + + # Clean up trailing angle brackets and other artifacts + content = re.sub(r"[<>]+\s*$", "", content, flags=re.MULTILINE) + + return content.strip() + + +if __name__ == "__main__": + content = sys.stdin.read() + processed = process_email_content(content) + print(processed) diff --git a/shell/run_himalaya.sh b/shell/run_himalaya.sh new file mode 100755 index 0000000..a9886d3 --- /dev/null +++ b/shell/run_himalaya.sh @@ -0,0 +1,262 @@ +#!/bin/bash + +# Enhanced Himalaya Email Management Script +# Features: Auto-discovery, smart navigation, streamlined UX + +# Function to get available message IDs from himalaya +get_available_message_ids() { + himalaya envelope list | awk 'NR > 2 && /^\| [0-9]/ {gsub(/[| ]/, "", $2); if($2 ~ /^[0-9]+$/) print $2}' | sort -n +} + +# Function to get the latest (most recent) message ID +get_latest_message_id() { + get_available_message_ids | tail -1 +} + +# Function to find the next valid message ID +find_next_message_id() { + local current_id="$1" + get_available_message_ids | awk -v current="$current_id" '$1 > current {print $1; exit}' +} + +# Function to find the previous valid message ID +find_previous_message_id() { + local current_id="$1" + get_available_message_ids | awk -v current="$current_id" '$1 < current {prev=$1} END {if(prev) print prev}' +} + +# Function to refresh the vim-himalaya buffer +refresh_vim_himalaya() { + if [ -S "/tmp/nvim-server" ]; then + nvim --server /tmp/nvim-server --remote-send "Himalaya" 2>/dev/null + fi +} + +# 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() { + local message_id="$1" + local temp_output + temp_output=$(himalaya message read "$message_id" 2>&1) + local exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Choose email processor based on availability + local email_processor + if [ -f "email_processor.awk" ]; then + email_processor="awk -f email_processor.awk" + elif command -v python3 >/dev/null 2>&1 && [ -f "email_processor.py" ]; then + email_processor="python3 email_processor.py" + else + email_processor="cat" # fallback to no processing + fi + + # Process email content, then render with glow and display with less + echo "$temp_output" | \ + $email_processor | \ + # bat + glow --width 100 -p + # less -R -X -F + return 0 + else + echo "Failed to open message $message_id." + return 1 + fi +} + +# Function to create a task for the current message +create_task_for_message() { + local message_id="$1" + read -p "Task title: " task_title + if [ -n "$task_title" ]; then + if command -v task >/dev/null 2>&1; then + task add "Followup on email $message_id - $task_title" \ + --project "Email" \ + --due "$(date -d '+1 week' +%Y-%m-%d)" \ + --priority "P3" \ + --tags "email" + echo "Task created for message $message_id." + else + echo "TaskWarrior not found. Task not created." + fi + else + echo "No task title provided. Task not created." + fi +} + +# Function to display the action menu and handle user choice +show_action_menu() { + local current_id="$1" + + # Draw a nice border above the menu + echo "" + echo "════════════════════════════════════════════════════════════════════════════════" + echo " ACTIONS" + echo "════════════════════════════════════════════════════════════════════════════════" + echo "" + echo " t) Create task for this message" + echo " d) Delete message" + echo " a) Archive message" + echo " n) Next message" + echo " p) Previous message" + echo " r) Reopen/redisplay current message" + echo " q) Quit" + echo "" + echo "────────────────────────────────────────────────────────────────────────────────" + echo -n "Enter choice: " + + local choice=$(read_char) + echo "$choice" # Echo for feedback + + # Clear screen after choice is made + clear + + case "$choice" in + t|T) + create_task_for_message "$current_id" + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + ;; + d|D) + echo "Deleting message $current_id..." + if himalaya message delete "$current_id"; then + echo "Message $current_id deleted successfully." + refresh_vim_himalaya + # Try to open next available message + local next_id=$(find_next_message_id "$current_id") + if [ -n "$next_id" ]; then + echo "Opening next message: $next_id" + process_message "$next_id" + else + echo "No more messages. Exiting." + exit 0 + fi + else + echo "Failed to delete message $current_id." + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + fi + ;; + a|A) + echo "Archiving message $current_id..." + if himalaya message move Archives "$current_id"; then + echo "Message $current_id archived successfully." + refresh_vim_himalaya + # Try to open next available message + local next_id=$(find_next_message_id "$current_id") + if [ -n "$next_id" ]; then + echo "Opening next message: $next_id" + process_message "$next_id" + else + echo "No more messages. Exiting." + exit 0 + fi + else + echo "Failed to archive message $current_id." + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + fi + ;; + n|N) + local next_id=$(find_next_message_id "$current_id") + if [ -n "$next_id" ]; then + echo "Opening next message: $next_id" + process_message "$next_id" + else + echo "No next message available." + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + fi + ;; + p|P) + local prev_id=$(find_previous_message_id "$current_id") + if [ -n "$prev_id" ]; then + echo "Opening previous message: $prev_id" + process_message "$prev_id" + else + echo "No previous message available." + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + fi + ;; + r|R) + echo "Reopening message $current_id..." + process_message "$current_id" + ;; + q|Q) + echo "Exiting." + exit 0 + ;; + *) + echo "Invalid choice. Please try again." + echo "Press any key to continue..." + read_char > /dev/null + process_message "$current_id" + ;; + esac +} + +# Function to process a message (read and show menu) +process_message() { + local message_id="$1" + local is_fallback="$2" + + echo "Reading message $message_id..." + if run_himalaya_message_read "$message_id"; then + show_action_menu "$message_id" + else + echo "Error reading message $message_id." + if [ "$is_fallback" = "true" ]; then + # If this was already a fallback attempt, don't try again + echo "Unable to read any messages. Exiting." + exit 1 + else + # Try to find a valid message to fall back to + echo "Trying to find an available message..." + local latest_id=$(get_latest_message_id) + if [ -n "$latest_id" ] && [ "$latest_id" != "$message_id" ]; then + echo "Opening latest message: $latest_id" + process_message "$latest_id" "true" + else + echo "No valid messages found. Exiting." + exit 1 + fi + fi + fi +} + +# Main script logic + +# Check if message ID is provided, otherwise get the latest +if [ -z "$1" ]; then + echo "No message ID provided. Finding latest message..." + message_id=$(get_latest_message_id) + if [ -z "$message_id" ]; then + echo "No messages found in inbox." + exit 1 + fi + echo "Latest message ID: $message_id" +else + message_id="$1" +fi + +# Validate that himalaya is available +if ! command -v himalaya >/dev/null 2>&1; then + echo "Error: himalaya command not found. Please install himalaya." + exit 1 +fi + +# Start processing the message +process_message "$message_id" diff --git a/shell/run_tests.sh b/shell/run_tests.sh new file mode 100755 index 0000000..c63d017 --- /dev/null +++ b/shell/run_tests.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Test runner for run_himalaya.sh + +echo "====================" +echo "Himalaya Script Test Suite" +echo "====================" +echo + +# Check if glow is available (needed for message display) +if ! command -v glow >/dev/null 2>&1; then + echo "Warning: glow not found. Some tests may behave differently." + echo "Install with: brew install glow" + echo +fi + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Set up PATH to include our test mocks +export PATH="$SCRIPT_DIR/tests:$PATH" + +# Run unit tests +echo "=== Unit Tests ===" +if ! bash "$SCRIPT_DIR/tests/unit_tests.sh"; then + echo "Unit tests failed!" + exit 1 +fi + +echo +echo "=== Integration Tests ===" +if ! bash "$SCRIPT_DIR/tests/integration_tests.sh"; then + echo "Integration tests failed!" + exit 1 +fi + +echo +echo "====================" +echo "All tests passed! ✅" +echo "====================" \ No newline at end of file diff --git a/test_refactored.sh b/shell/test_refactored.sh similarity index 100% rename from test_refactored.sh rename to shell/test_refactored.sh diff --git a/src/cli/sync.py b/src/cli/sync.py index ddf99f2..ca0b630 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -16,6 +16,7 @@ from src.services.microsoft_graph.mail import ( ) from src.services.microsoft_graph.auth import get_access_token + # Function to create Maildir structure def create_maildir_structure(base_path): """ @@ -34,7 +35,18 @@ def create_maildir_structure(base_path): ensure_directory_exists(os.path.join(base_path, ".Trash", "cur")) -async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, ics_path, org_name, days_back, days_forward, continue_iteration): +async def fetch_calendar_async( + headers, + progress, + task_id, + dry_run, + vdir_path, + ics_path, + org_name, + days_back, + days_forward, + continue_iteration, +): """ Fetch calendar events and save them in the appropriate format. @@ -73,8 +85,7 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i progress.console.print( f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]" ) - save_events_to_vdir(events, org_vdir_path, - progress, task_id, dry_run) + save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run) progress.console.print( f"[green]Finished saving events to vdir: {org_vdir_path}[/green]" ) @@ -97,7 +108,8 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i else: progress.console.print( f"[DRY-RUN] Would save {len(events)} events to { - 'vdir format' if vdir_path else 'single ICS file'}" + 'vdir format' if vdir_path else 'single ICS file' + }" ) progress.update(task_id, advance=len(events)) @@ -108,17 +120,22 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i next_end_date = next_start_date + timedelta(days=days_forward) progress.console.print( - f"\nCurrent date range: {next_start_date.strftime( - '%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}" + f"\nCurrent date range: {next_start_date.strftime('%Y-%m-%d')} to { + next_end_date.strftime('%Y-%m-%d') + }" ) - user_response = click.prompt( - "\nContinue to iterate? [y/N]", default="N").strip().lower() + user_response = ( + click.prompt("\nContinue to iterate? [y/N]", default="N") + .strip() + .lower() + ) while user_response == "y": progress.console.print( - f"\nFetching events for {next_start_date.strftime( - '%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}..." + f"\nFetching events for {next_start_date.strftime('%Y-%m-%d')} to { + next_end_date.strftime('%Y-%m-%d') + }..." ) # Reset the progress bar for the new fetch @@ -152,7 +169,12 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i else: progress.console.print( f"[DRY-RUN] Would save {len(next_events)} events to { - 'vdir format' if vdir_path else 'output_ics/outlook_events_' + next_start_date.strftime('%Y%m%d') + '.ics'}" + 'vdir format' + if vdir_path + else 'output_ics/outlook_events_' + + next_start_date.strftime('%Y%m%d') + + '.ics' + }" ) progress.update(task_id, advance=len(next_events)) @@ -161,11 +183,15 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i next_end_date = next_start_date + timedelta(days=days_forward) progress.console.print( - f"\nNext date range would be: {next_start_date.strftime( - '%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}" + f"\nNext date range would be: { + next_start_date.strftime('%Y-%m-%d') + } to {next_end_date.strftime('%Y-%m-%d')}" + ) + user_response = ( + click.prompt("\nContinue to iterate? [y/N]", default="N") + .strip() + .lower() ) - user_response = click.prompt( - "\nContinue to iterate? [y/N]", default="N").strip().lower() return events except Exception as e: @@ -179,17 +205,23 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i return [] -async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration, download_attachments): +async def _sync_outlook_data( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, +): """Synchronize data from external sources.""" # Expand the user home directory in vdir path vdir = os.path.expanduser(vdir) # Save emails to Maildir - maildir_path = ( - os.getenv("MAILDIR_PATH", os.path.expanduser( - "~/Mail")) + f"/{org}" - ) + maildir_path = os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}" attachments_dir = os.path.join(maildir_path, "attachments") ensure_directory_exists(attachments_dir) create_maildir_structure(maildir_path) @@ -210,27 +242,28 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar with progress: task_fetch = progress.add_task("[green]Syncing Inbox...", total=0) - task_calendar = progress.add_task( - "[cyan]Fetching calendar...", total=0) + task_calendar = progress.add_task("[cyan]Fetching calendar...", total=0) task_read = progress.add_task("[blue]Marking as read...", total=0) task_archive = progress.add_task("[yellow]Archiving mail...", total=0) task_delete = progress.add_task("[red]Deleting mail...", total=0) # Stage 1: Synchronize local changes (read, archive, delete) to the server - progress.console.print("[bold cyan]Step 1: Syncing local changes to server...[/bold cyan]") + progress.console.print( + "[bold cyan]Step 1: Syncing local changes to server...[/bold cyan]" + ) await asyncio.gather( synchronize_maildir_async( maildir_path, headers, progress, task_read, dry_run ), - archive_mail_async(maildir_path, headers, - progress, task_archive, dry_run), - delete_mail_async(maildir_path, headers, - progress, task_delete, dry_run), + archive_mail_async(maildir_path, headers, progress, task_archive, dry_run), + delete_mail_async(maildir_path, headers, progress, task_delete, dry_run), ) progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]") # Stage 2: Fetch new data from the server - progress.console.print("\n[bold cyan]Step 2: Fetching new data from server...[/bold cyan]") + progress.console.print( + "\n[bold cyan]Step 2: Fetching new data from server...[/bold cyan]" + ) await asyncio.gather( fetch_mail_async( maildir_path, @@ -241,7 +274,18 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar dry_run, download_attachments, ), - fetch_calendar_async(headers, progress, task_calendar, dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration), + fetch_calendar_async( + headers, + progress, + task_calendar, + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + ), ) progress.console.print("[bold green]Step 2: New data fetched.[/bold green]") click.echo("Sync complete.") @@ -291,5 +335,123 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar help="Download email attachments", default=False, ) -def sync(dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration, download_attachments): - asyncio.run(_sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration, download_attachments)) \ No newline at end of file +@click.option( + "--daemon", + is_flag=True, + help="Run in daemon mode.", + default=False, +) +def sync( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, + daemon, +): + if daemon: + asyncio.run( + daemon_mode( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, + ) + ) + else: + asyncio.run( + _sync_outlook_data( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, + ) + ) + + +async def daemon_mode( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, +): + """ + Run the script in daemon mode, periodically syncing emails. + """ + from src.services.microsoft_graph.mail import get_inbox_count_async + import time + + sync_interval = 300 # 5 minutes + check_interval = 10 # 10 seconds + last_sync_time = time.time() - sync_interval # Force initial sync + + while True: + if time.time() - last_sync_time >= sync_interval: + click.echo("[green]Performing full sync...[/green]") + # Perform a full sync + await _sync_outlook_data( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, + ) + last_sync_time = time.time() + else: + # Perform a quick check + click.echo("[cyan]Checking for new messages...[/cyan]") + # Authenticate and get access token + scopes = ["https://graph.microsoft.com/Mail.Read"] + access_token, headers = get_access_token(scopes) + remote_message_count = await get_inbox_count_async(headers) + maildir_path = os.path.expanduser(f"~/Mail/{org}") + local_message_count = len( + [ + f + for f in os.listdir(os.path.join(maildir_path, "new")) + if ".eml" in f + ] + ) + len( + [ + f + for f in os.listdir(os.path.join(maildir_path, "cur")) + if ".eml" in f + ] + ) + if remote_message_count != local_message_count: + click.echo( + f"[yellow]New messages detected ({remote_message_count} / {local_message_count}), performing full sync...[/yellow]" + ) + await _sync_outlook_data( + dry_run, + vdir, + icsfile, + org, + days_back, + days_forward, + continue_iteration, + download_attachments, + ) + last_sync_time = time.time() + else: + click.echo("[green]No new messages detected.[/green]") + + time.sleep(check_interval) diff --git a/src/services/microsoft_graph/mail.py b/src/services/microsoft_graph/mail.py index 099c164..c2a5f75 100644 --- a/src/services/microsoft_graph/mail.py +++ b/src/services/microsoft_graph/mail.py @@ -116,8 +116,15 @@ async def archive_mail_async(maildir_path, headers, progress, task_id, dry_run=F Returns: None """ - archive_dir = os.path.join(maildir_path, ".Archives") - archive_files = glob.glob(os.path.join(archive_dir, "**", "*.eml*"), recursive=True) + # Check both possible archive folder names locally + archive_files = [] + for archive_folder_name in [".Archives", ".Archive"]: + archive_dir = os.path.join(maildir_path, archive_folder_name) + if os.path.exists(archive_dir): + archive_files.extend( + glob.glob(os.path.join(archive_dir, "**", "*.eml*"), recursive=True) + ) + progress.update(task_id, total=len(archive_files)) folder_response = await fetch_with_aiohttp( @@ -128,13 +135,13 @@ async def archive_mail_async(maildir_path, headers, progress, task_id, dry_run=F ( folder.get("id") for folder in folders - if folder.get("displayName", "").lower() == "archive" + if folder.get("displayName", "").lower() in ["archive", "archives"] ), None, ) if not archive_folder_id: - raise Exception("No folder named 'Archive' found on the server.") + raise Exception("No folder named 'Archive' or 'Archives' found on the server.") for filepath in archive_files: message_id = os.path.basename(filepath).split(".")[ @@ -147,17 +154,22 @@ async def archive_mail_async(maildir_path, headers, progress, task_id, dry_run=F headers, {"destinationId": archive_folder_id}, ) - if status != 201: # 201 Created indicates success - progress.console.print( - f"Failed to move message to 'Archive': {message_id}, {status}" - ) - if status == 404: - os.remove(filepath) # Remove the file from local archive if not found + if status == 201: # 201 Created indicates successful move + os.remove( + filepath + ) # Remove the local file since it's now archived on server + progress.console.print(f"Moved message to 'Archive': {message_id}") + elif status == 404: + os.remove( + filepath + ) # Remove the file from local archive if not found on server progress.console.print( f"Message not found on server, removed local copy: {message_id}" ) - elif status == 204: - progress.console.print(f"Moved message to 'Archive': {message_id}") + else: + progress.console.print( + f"Failed to move message to 'Archive': {message_id}, status: {status}" + ) else: progress.console.print( f"[DRY-RUN] Would move message to 'Archive' folder: {message_id}" @@ -200,6 +212,21 @@ async def delete_mail_async(maildir_path, headers, progress, task_id, dry_run=Fa progress.advance(task_id) +async def get_inbox_count_async(headers): + """ + Get the number of messages in the inbox. + + Args: + headers (dict): Headers including authentication. + + Returns: + int: The number of messages in the inbox. + """ + inbox_url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox" + response = await fetch_with_aiohttp(inbox_url, headers) + return response.get("totalItemCount", 0) + + async def synchronize_maildir_async( maildir_path, headers, progress, task_id, dry_run=False ): @@ -217,10 +244,10 @@ async def synchronize_maildir_async( None """ from src.utils.mail_utils.helpers import ( - load_last_sync_timestamp, - save_sync_timestamp, - truncate_id, -) + load_last_sync_timestamp, + save_sync_timestamp, + truncate_id, + ) last_sync = load_last_sync_timestamp() diff --git a/tests/fixtures/envelope_list_empty.txt b/tests/fixtures/envelope_list_empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/envelope_list_normal.txt b/tests/fixtures/envelope_list_normal.txt new file mode 100644 index 0000000..2e37990 --- /dev/null +++ b/tests/fixtures/envelope_list_normal.txt @@ -0,0 +1,6 @@ +| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00| \ No newline at end of file diff --git a/tests/fixtures/envelope_list_single.txt b/tests/fixtures/envelope_list_single.txt new file mode 100644 index 0000000..63f9ac1 --- /dev/null +++ b/tests/fixtures/envelope_list_single.txt @@ -0,0 +1,3 @@ +| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 42 | | Only Message | single@example.com | 2024-01-15 10:00+00:00| \ No newline at end of file diff --git a/tests/fixtures/message_content_1.txt b/tests/fixtures/message_content_1.txt new file mode 100644 index 0000000..9e5c9a1 --- /dev/null +++ b/tests/fixtures/message_content_1.txt @@ -0,0 +1,11 @@ +From: john@example.com +To: user@example.com +Subject: Important Meeting +Date: 2024-01-15 + +Hi there, + +We need to schedule an important meeting for next week. Please let me know your availability. + +Best regards, +John \ No newline at end of file diff --git a/tests/himalaya b/tests/himalaya new file mode 120000 index 0000000..fce8268 --- /dev/null +++ b/tests/himalaya @@ -0,0 +1 @@ +mock_himalaya.sh \ No newline at end of file diff --git a/tests/integration_tests.sh b/tests/integration_tests.sh new file mode 100755 index 0000000..050ad79 --- /dev/null +++ b/tests/integration_tests.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +# Integration tests for run_himalaya.sh + +# Source test utilities +source "$(dirname "$0")/test_utils.sh" + +# Make sure we can find the mock himalaya command +export PATH="$(dirname "$0"):$PATH" + +# Common test data in real himalaya format +NORMAL_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00|" + +EXTENDED_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00|" + +# Test: Script with no arguments should auto-discover latest message +test_script_auto_discover() { + export MOCK_ENVELOPE_LIST="$NORMAL_ENVELOPE_LIST" + + # Simulate user input: quit immediately + local output=$(echo "q" | timeout 10 ./run_himalaya.sh 2>/dev/null) + local exit_code=$? + + # Check that it found the latest message (5) and attempted to read it + if echo "$output" | grep -q "Latest message ID: 5" && echo "$output" | grep -q "Reading message 5"; then + return 0 + else + echo "Expected auto-discovery of message 5, got: $output" + return 1 + fi +} + +# Test: Script with valid message ID +test_script_valid_id() { + export MOCK_ENVELOPE_LIST="$NORMAL_ENVELOPE_LIST" + + # Simulate user input: quit immediately + local output=$(echo "q" | timeout 10 ./run_himalaya.sh 1 2>/dev/null) + + # Check that it read the specified message + if echo "$output" | grep -q "Reading message 1"; then + return 0 + else + echo "Expected reading of message 1, got: $output" + return 1 + fi +} + +# Test: Script with invalid message ID should fallback to latest +test_script_invalid_id_fallback() { + export MOCK_ENVELOPE_LIST="$NORMAL_ENVELOPE_LIST" + export MOCK_INVALID_MESSAGE_ID="99" + + # Simulate user input: quit immediately + local output=$(echo "q" | timeout 10 ./run_himalaya.sh 99 2>/dev/null) + + # Check that it fell back to latest message + if echo "$output" | grep -q "Opening latest message: 5"; then + return 0 + else + echo "Expected fallback to latest message, got: $output" + return 1 + fi +} + +# Test: Empty inbox handling +test_script_empty_inbox() { + export MOCK_EMPTY_INBOX="true" + + # Don't use timeout as it may interfere with exit codes + # Use bash -c to avoid subshell exit code issues + bash -c './run_himalaya.sh' > "$TEST_TEMP_DIR/output.txt" 2>&1 + local exit_code=$? + local output=$(cat "$TEST_TEMP_DIR/output.txt") + + # Should exit with error message about no messages and exit code 1 + if echo "$output" | grep -q "No messages found in inbox" && [ $exit_code -eq 1 ]; then + return 0 + else + echo "Expected 'No messages found' error with exit code 1, got: $output (exit code: $exit_code)" + return 1 + fi +} + +# Test: Navigation to next message +test_navigation_next() { + export MOCK_ENVELOPE_LIST="$EXTENDED_ENVELOPE_LIST" + + # Simulate user input: next message, then quit + local output=$(printf "n\nq\n" | timeout 10 ./run_himalaya.sh 1 2>/dev/null) + + # Check that it moved to next message (5) + if echo "$output" | grep -q "Opening next message: 5" && echo "$output" | grep -q "Reading message 5"; then + return 0 + else + echo "Expected navigation to next message 5, got: $output" + return 1 + fi +} + +# Test: Navigation to previous message +test_navigation_previous() { + export MOCK_ENVELOPE_LIST="$EXTENDED_ENVELOPE_LIST" + + # Simulate user input: previous message, then quit + local output=$(printf "p\nq\n" | timeout 10 ./run_himalaya.sh 7 2>/dev/null) + + # Check that it moved to previous message (5) + if echo "$output" | grep -q "Opening previous message: 5" && echo "$output" | grep -q "Reading message 5"; then + return 0 + else + echo "Expected navigation to previous message 5, got: $output" + return 1 + fi +} + +# Test: No next message available +test_navigation_no_next() { + export MOCK_ENVELOPE_LIST="$NORMAL_ENVELOPE_LIST" + + # Simulate user input: next message (should fail), then quit + local output=$(printf "n\nq\n" | timeout 10 ./run_himalaya.sh 5 2>/dev/null) + + # Check that it shows "No next message available" + if echo "$output" | grep -q "No next message available"; then + return 0 + else + echo "Expected 'No next message available', got: $output" + return 1 + fi +} + +# Run all tests +echo "Running integration tests for run_himalaya.sh..." +echo + +run_test "Script auto-discovers latest message when no args" test_script_auto_discover +run_test "Script reads specified valid message ID" test_script_valid_id +run_test "Script falls back to latest when invalid ID provided" test_script_invalid_id_fallback +run_test "Script handles empty inbox gracefully" test_script_empty_inbox +run_test "Navigation to next message works" test_navigation_next +run_test "Navigation to previous message works" test_navigation_previous +run_test "No next message available handled gracefully" test_navigation_no_next + +print_test_summary \ No newline at end of file diff --git a/tests/mock_himalaya.sh b/tests/mock_himalaya.sh new file mode 100755 index 0000000..644efec --- /dev/null +++ b/tests/mock_himalaya.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Mock himalaya command for testing +# Controlled by environment variables for different test scenarios + +# Mock less command that doesn't wait for input in tests +if [ -z "$MOCK_LESS_INTERACTIVE" ]; then + # In test mode, just output directly without paging + alias less='cat' +fi + +# Default test data - matches real himalaya output format +DEFAULT_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00|" + +DEFAULT_MESSAGE_CONTENT="From: test@example.com +To: user@example.com +Subject: Test Message +Date: 2024-01-15 + +This is a test message content for testing purposes." + +# Handle different commands +case "$1" in + "envelope") + case "$2" in + "list") + if [ -n "$MOCK_ENVELOPE_LIST" ]; then + echo "$MOCK_ENVELOPE_LIST" + elif [ "$MOCK_EMPTY_INBOX" = "true" ]; then + echo "" + elif [ "$MOCK_HIMALAYA_FAIL" = "true" ]; then + echo "Error: Unable to connect to server" >&2 + exit 1 + else + echo "$DEFAULT_ENVELOPE_LIST" + fi + ;; + esac + ;; + "message") + case "$2" in + "read") + message_id="$3" + if [ "$MOCK_HIMALAYA_FAIL" = "true" ]; then + echo "Error: Unable to read message $message_id" >&2 + exit 1 + elif [ -n "$MOCK_INVALID_MESSAGE_ID" ] && [ "$message_id" = "$MOCK_INVALID_MESSAGE_ID" ]; then + echo "Error: Message $message_id not found" >&2 + exit 1 + elif [ -n "$MOCK_ENVELOPE_LIST" ]; then + # Check if the message ID exists in our envelope list + # Handle both old tab-separated format and new table format + if echo "$MOCK_ENVELOPE_LIST" | grep -q "^$message_id " || echo "$MOCK_ENVELOPE_LIST" | grep -q "^| $message_id "; then + if [ -n "$MOCK_MESSAGE_CONTENT" ]; then + echo "$MOCK_MESSAGE_CONTENT" + else + echo "$DEFAULT_MESSAGE_CONTENT" + fi + else + echo "Error: Message $message_id not found" >&2 + exit 1 + fi + else + if [ -n "$MOCK_MESSAGE_CONTENT" ]; then + echo "$MOCK_MESSAGE_CONTENT" + else + echo "$DEFAULT_MESSAGE_CONTENT" + fi + fi + ;; + "delete") + message_id="$3" + if [ "$MOCK_HIMALAYA_FAIL" = "true" ]; then + echo "Error: Unable to delete message $message_id" >&2 + exit 1 + else + echo "Message $message_id deleted successfully" + fi + ;; + "move") + folder="$3" + message_id="$4" + if [ "$MOCK_HIMALAYA_FAIL" = "true" ]; then + echo "Error: Unable to move message $message_id to $folder" >&2 + exit 1 + else + echo "Message $message_id moved to $folder successfully" + fi + ;; + esac + ;; + *) + echo "Mock himalaya: Unknown command $1" >&2 + exit 1 + ;; +esac \ No newline at end of file diff --git a/tests/test_utils.sh b/tests/test_utils.sh new file mode 100644 index 0000000..3acc8cf --- /dev/null +++ b/tests/test_utils.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# Test utilities for run_himalaya.sh testing + +# Colors for test output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Setup function - prepares test environment +setup_test() { + # Clear any existing mock environment variables + unset MOCK_ENVELOPE_LIST + unset MOCK_MESSAGE_CONTENT + unset MOCK_EMPTY_INBOX + unset MOCK_HIMALAYA_FAIL + unset MOCK_INVALID_MESSAGE_ID + + # Set up PATH to use mock himalaya + export PATH="$(pwd)/tests:$PATH" + + # Create temporary files for testing + export TEST_TEMP_DIR="/tmp/himalaya_test_$$" + mkdir -p "$TEST_TEMP_DIR" +} + +# Cleanup function +cleanup_test() { + # Remove temporary files + if [ -n "$TEST_TEMP_DIR" ] && [ -d "$TEST_TEMP_DIR" ]; then + rm -rf "$TEST_TEMP_DIR" + fi + + # Reset PATH + export PATH=$(echo "$PATH" | sed "s|$(pwd)/tests:||") +} + +# Function to simulate user input +simulate_input() { + local input="$1" + echo "$input" +} + +# Function to run a test +run_test() { + local test_name="$1" + local test_function="$2" + + echo -e "${YELLOW}Running: $test_name${NC}" + TESTS_RUN=$((TESTS_RUN + 1)) + + setup_test + + if $test_function; then + echo -e "${GREEN}✓ PASS: $test_name${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗ FAIL: $test_name${NC}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi + + cleanup_test + echo +} + +# Function to assert equality +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + if [ "$expected" = "$actual" ]; then + return 0 + else + echo -e "${RED}Assertion failed: $message${NC}" + echo -e "${RED}Expected: '$expected'${NC}" + echo -e "${RED}Actual: '$actual'${NC}" + return 1 + fi +} + +# Function to assert command success +assert_success() { + local command="$1" + local message="$2" + + if eval "$command" >/dev/null 2>&1; then + return 0 + else + echo -e "${RED}Command failed: $message${NC}" + echo -e "${RED}Command: $command${NC}" + return 1 + fi +} + +# Function to assert command failure +assert_failure() { + local command="$1" + local message="$2" + + if eval "$command" >/dev/null 2>&1; then + echo -e "${RED}Command unexpectedly succeeded: $message${NC}" + echo -e "${RED}Command: $command${NC}" + return 1 + else + return 0 + fi +} + +# Function to print test summary +print_test_summary() { + echo "====================" + echo "Test Summary" + echo "====================" + echo "Tests run: $TESTS_RUN" + echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" + echo -e "Failed: ${RED}$TESTS_FAILED${NC}" + + if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + return 0 + else + echo -e "${RED}Some tests failed!${NC}" + return 1 + fi +} \ No newline at end of file diff --git a/tests/unit_tests.sh b/tests/unit_tests.sh new file mode 100755 index 0000000..4ff04ba --- /dev/null +++ b/tests/unit_tests.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# Unit tests for run_himalaya.sh functions + +# Source test utilities +source "$(dirname "$0")/test_utils.sh" + +# Make sure we can find the mock himalaya command +export PATH="$(dirname "$0"):$PATH" + +# Source the functions from run_himalaya.sh for testing +# We need to extract just the functions for testing +create_function_file() { + # Create a temporary file with just the functions + cat > "$TEST_TEMP_DIR/functions.sh" << 'EOFUNC' +#!/bin/bash + +# Function to get available message IDs from himalaya +get_available_message_ids() { + himalaya envelope list | awk 'NR > 2 && /^\| [0-9]/ {gsub(/[| ]/, "", $2); if($2 ~ /^[0-9]+$/) print $2}' | sort -n +} + +# Function to get the latest (most recent) message ID +get_latest_message_id() { + get_available_message_ids | tail -1 +} + +# Function to find the next valid message ID +find_next_message_id() { + local current_id="$1" + get_available_message_ids | awk -v current="$current_id" '$1 > current {print $1; exit}' +} + +# Function to find the previous valid message ID +find_previous_message_id() { + local current_id="$1" + get_available_message_ids | awk -v current="$current_id" '$1 < current {prev=$1} END {if(prev) print prev}' +} +EOFUNC + source "$TEST_TEMP_DIR/functions.sh" +} + +# Test: get_available_message_ids with normal data +test_get_available_message_ids_normal() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00|" + + create_function_file + local result=$(get_available_message_ids | tr '\n' ' ') + local expected="1 5 7 10 " + + assert_equals "$expected" "$result" "Should return sorted message IDs" +} + +# Test: get_available_message_ids with empty inbox +test_get_available_message_ids_empty() { + export MOCK_EMPTY_INBOX="true" + + create_function_file + local result=$(get_available_message_ids) + + assert_equals "" "$result" "Should return empty for empty inbox" +} + +# Test: get_latest_message_id with normal data +test_get_latest_message_id_normal() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00|" + + create_function_file + local result=$(get_latest_message_id) + + assert_equals "10" "$result" "Should return the highest message ID" +} + +# Test: get_latest_message_id with single message +test_get_latest_message_id_single() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 42 | | Only Message | single@example.com | 2024-01-15 10:00+00:00|" + + create_function_file + local result=$(get_latest_message_id) + + assert_equals "42" "$result" "Should return the single message ID" +} + +# Test: find_next_message_id with valid next +test_find_next_message_id_valid() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00|" + + create_function_file + local result=$(find_next_message_id "5") + + assert_equals "7" "$result" "Should return next available message ID" +} + +# Test: find_next_message_id with no next available +test_find_next_message_id_none() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00|" + + create_function_file + local result=$(find_next_message_id "5") + + assert_equals "" "$result" "Should return empty when no next message" +} + +# Test: find_previous_message_id with valid previous +test_find_previous_message_id_valid() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 1 | | Important Meeting | john@example.com | 2024-01-15 10:00+00:00| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00| +| 10 | | Contract Review | client@business.com | 2024-01-12 14:00+00:00|" + + create_function_file + local result=$(find_previous_message_id "7") + + assert_equals "5" "$result" "Should return previous available message ID" +} + +# Test: find_previous_message_id with no previous available +test_find_previous_message_id_none() { + export MOCK_ENVELOPE_LIST="| ID | FLAGS | SUBJECT | FROM | DATE | +|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------| +| 5 | * | Project Update | sarah@company.com | 2024-01-14 15:30+00:00| +| 7 | | Weekly Standup | team@startup.com | 2024-01-13 09:00+00:00|" + + create_function_file + local result=$(find_previous_message_id "5") + + assert_equals "" "$result" "Should return empty when no previous message" +} + +# Run all tests +echo "Running unit tests for run_himalaya.sh functions..." +echo + +run_test "get_available_message_ids with normal data" test_get_available_message_ids_normal +run_test "get_available_message_ids with empty inbox" test_get_available_message_ids_empty +run_test "get_latest_message_id with normal data" test_get_latest_message_id_normal +run_test "get_latest_message_id with single message" test_get_latest_message_id_single +run_test "find_next_message_id with valid next" test_find_next_message_id_valid +run_test "find_next_message_id with no next available" test_find_next_message_id_none +run_test "find_previous_message_id with valid previous" test_find_previous_message_id_valid +run_test "find_previous_message_id with no previous available" test_find_previous_message_id_none + +print_test_summary \ No newline at end of file