trying a simple shell script and fixing archives

This commit is contained in:
Tim Bendt
2025-07-15 22:13:46 -04:00
parent f7474a3805
commit df4c49c3ef
18 changed files with 1273 additions and 389 deletions

View File

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

View File

@@ -1,140 +0,0 @@
#!/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"

38
shell/email_processor.awk Executable file
View File

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

125
shell/email_processor.py Executable file
View File

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

262
shell/run_himalaya.sh Executable file
View File

@@ -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<CR>" 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"

41
shell/run_tests.sh Executable file
View File

@@ -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 "===================="

View File

@@ -16,6 +16,7 @@ from src.services.microsoft_graph.mail import (
) )
from src.services.microsoft_graph.auth import get_access_token from src.services.microsoft_graph.auth import get_access_token
# Function to create Maildir structure # Function to create Maildir structure
def create_maildir_structure(base_path): 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")) 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. 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( progress.console.print(
f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]" f"[cyan]Saving events to vdir: {org_vdir_path}[/cyan]"
) )
save_events_to_vdir(events, org_vdir_path, save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run)
progress, task_id, dry_run)
progress.console.print( progress.console.print(
f"[green]Finished saving events to vdir: {org_vdir_path}[/green]" 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: else:
progress.console.print( progress.console.print(
f"[DRY-RUN] Would save {len(events)} events to { 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)) 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) next_end_date = next_start_date + timedelta(days=days_forward)
progress.console.print( progress.console.print(
f"\nCurrent date range: {next_start_date.strftime( f"\nCurrent date range: {next_start_date.strftime('%Y-%m-%d')} to {
'%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}" next_end_date.strftime('%Y-%m-%d')
}"
) )
user_response = click.prompt( user_response = (
"\nContinue to iterate? [y/N]", default="N").strip().lower() click.prompt("\nContinue to iterate? [y/N]", default="N")
.strip()
.lower()
)
while user_response == "y": while user_response == "y":
progress.console.print( progress.console.print(
f"\nFetching events for {next_start_date.strftime( f"\nFetching events for {next_start_date.strftime('%Y-%m-%d')} to {
'%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}..." next_end_date.strftime('%Y-%m-%d')
}..."
) )
# Reset the progress bar for the new fetch # 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: else:
progress.console.print( progress.console.print(
f"[DRY-RUN] Would save {len(next_events)} events to { 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)) 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) next_end_date = next_start_date + timedelta(days=days_forward)
progress.console.print( progress.console.print(
f"\nNext date range would be: {next_start_date.strftime( f"\nNext date range would be: {
'%Y-%m-%d')} to {next_end_date.strftime('%Y-%m-%d')}" 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 return events
except Exception as e: except Exception as e:
@@ -179,17 +205,23 @@ async def fetch_calendar_async(headers, progress, task_id, dry_run, vdir_path, i
return [] 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.""" """Synchronize data from external sources."""
# Expand the user home directory in vdir path # Expand the user home directory in vdir path
vdir = os.path.expanduser(vdir) vdir = os.path.expanduser(vdir)
# Save emails to Maildir # Save emails to Maildir
maildir_path = ( maildir_path = os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}"
os.getenv("MAILDIR_PATH", os.path.expanduser(
"~/Mail")) + f"/{org}"
)
attachments_dir = os.path.join(maildir_path, "attachments") attachments_dir = os.path.join(maildir_path, "attachments")
ensure_directory_exists(attachments_dir) ensure_directory_exists(attachments_dir)
create_maildir_structure(maildir_path) 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: with progress:
task_fetch = progress.add_task("[green]Syncing Inbox...", total=0) task_fetch = progress.add_task("[green]Syncing Inbox...", total=0)
task_calendar = progress.add_task( task_calendar = progress.add_task("[cyan]Fetching calendar...", total=0)
"[cyan]Fetching calendar...", total=0)
task_read = progress.add_task("[blue]Marking as read...", total=0) task_read = progress.add_task("[blue]Marking as read...", total=0)
task_archive = progress.add_task("[yellow]Archiving mail...", total=0) task_archive = progress.add_task("[yellow]Archiving mail...", total=0)
task_delete = progress.add_task("[red]Deleting mail...", total=0) task_delete = progress.add_task("[red]Deleting mail...", total=0)
# Stage 1: Synchronize local changes (read, archive, delete) to the server # 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( await asyncio.gather(
synchronize_maildir_async( synchronize_maildir_async(
maildir_path, headers, progress, task_read, dry_run maildir_path, headers, progress, task_read, dry_run
), ),
archive_mail_async(maildir_path, headers, archive_mail_async(maildir_path, headers, progress, task_archive, dry_run),
progress, task_archive, dry_run), delete_mail_async(maildir_path, headers, progress, task_delete, 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]") progress.console.print("[bold green]Step 1: Local changes synced.[/bold green]")
# Stage 2: Fetch new data from the server # 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( await asyncio.gather(
fetch_mail_async( fetch_mail_async(
maildir_path, maildir_path,
@@ -241,7 +274,18 @@ async def _sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forwar
dry_run, dry_run,
download_attachments, 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]") progress.console.print("[bold green]Step 2: New data fetched.[/bold green]")
click.echo("Sync complete.") 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", help="Download email attachments",
default=False, default=False,
) )
def sync(dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration, download_attachments): @click.option(
asyncio.run(_sync_outlook_data(dry_run, vdir, icsfile, org, days_back, days_forward, continue_iteration, download_attachments)) "--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)

View File

@@ -116,8 +116,15 @@ async def archive_mail_async(maildir_path, headers, progress, task_id, dry_run=F
Returns: Returns:
None None
""" """
archive_dir = os.path.join(maildir_path, ".Archives") # Check both possible archive folder names locally
archive_files = glob.glob(os.path.join(archive_dir, "**", "*.eml*"), recursive=True) 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)) progress.update(task_id, total=len(archive_files))
folder_response = await fetch_with_aiohttp( 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") folder.get("id")
for folder in folders for folder in folders
if folder.get("displayName", "").lower() == "archive" if folder.get("displayName", "").lower() in ["archive", "archives"]
), ),
None, None,
) )
if not archive_folder_id: 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: for filepath in archive_files:
message_id = os.path.basename(filepath).split(".")[ 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, headers,
{"destinationId": archive_folder_id}, {"destinationId": archive_folder_id},
) )
if status != 201: # 201 Created indicates success if status == 201: # 201 Created indicates successful move
progress.console.print( os.remove(
f"Failed to move message to 'Archive': {message_id}, {status}" filepath
) ) # Remove the local file since it's now archived on server
if status == 404: progress.console.print(f"Moved message to 'Archive': {message_id}")
os.remove(filepath) # Remove the file from local archive if not found elif status == 404:
os.remove(
filepath
) # Remove the file from local archive if not found on server
progress.console.print( progress.console.print(
f"Message not found on server, removed local copy: {message_id}" f"Message not found on server, removed local copy: {message_id}"
) )
elif status == 204: else:
progress.console.print(f"Moved message to 'Archive': {message_id}") progress.console.print(
f"Failed to move message to 'Archive': {message_id}, status: {status}"
)
else: else:
progress.console.print( progress.console.print(
f"[DRY-RUN] Would move message to 'Archive' folder: {message_id}" 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) 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( async def synchronize_maildir_async(
maildir_path, headers, progress, task_id, dry_run=False maildir_path, headers, progress, task_id, dry_run=False
): ):

View File

View File

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

View File

@@ -0,0 +1,3 @@
| ID | FLAGS | SUBJECT | FROM | DATE |
|------|-------|---------------------------------------------------------------------------------------------------|------------------------------|------------------------|
| 42 | | Only Message | single@example.com | 2024-01-15 10:00+00:00|

11
tests/fixtures/message_content_1.txt vendored Normal file
View File

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

1
tests/himalaya Symbolic link
View File

@@ -0,0 +1 @@
mock_himalaya.sh

152
tests/integration_tests.sh Executable file
View File

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

101
tests/mock_himalaya.sh Executable file
View File

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

132
tests/test_utils.sh Normal file
View File

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

165
tests/unit_tests.sh Executable file
View File

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