diff --git a/src/mail/app.py b/src/mail/app.py index d9da1b0..e0bcb44 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -1003,9 +1003,21 @@ class EmailViewerApp(App): config = get_config() # Add search results header - header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})" + if results: + header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})" + else: + header_label = f"Search: '{query}' - No results found" envelopes_list.append(ListItem(GroupHeader(label=header_label))) + if not results: + # Clear the message viewer when no results + content_container = self.query_one(ContentContainer) + content_container.clear_content() + self.message_store.envelopes = [] + self.total_messages = 0 + self.current_message_id = 0 + return + # Create a temporary message store for search results search_store = MessageStore() search_store.load(results, self.sort_order_ascending) diff --git a/src/mail/widgets/ContentContainer.py b/src/mail/widgets/ContentContainer.py index fdab7d2..acb1ff6 100644 --- a/src/mail/widgets/ContentContainer.py +++ b/src/mail/widgets/ContentContainer.py @@ -176,6 +176,14 @@ class ContentContainer(ScrollableContainer): format_type = "text" if self.current_mode == "markdown" else "html" self.content_worker = self.fetch_message_content(message_id, format_type) + def clear_content(self) -> None: + """Clear the message content display.""" + self.content.update("") + self.html_content.update("") + self.current_content = None + self.current_message_id = None + self.border_title = "No message selected" + def _update_content(self, content: str | None) -> None: """Update the content widgets with the fetched content.""" if content is None: diff --git a/tests/fixtures/himalaya_test_config.toml b/tests/fixtures/himalaya_test_config.toml new file mode 100644 index 0000000..e6eabc2 --- /dev/null +++ b/tests/fixtures/himalaya_test_config.toml @@ -0,0 +1,24 @@ +# Himalaya Test Configuration +# +# This configuration file sets up a local Maildir test account for integration testing. +# Copy this file to ~/.config/himalaya/config.toml or merge with existing config. +# +# Usage: +# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account +# himalaya -c tests/fixtures/himalaya_test_config.toml envelope list -a test-account from edson +# +# Or set the config path and use the test account: +# export HIMALAYA_CONFIG=tests/fixtures/himalaya_test_config.toml +# himalaya envelope list -a test-account + +[accounts.test-account] +default = true +email = "test@example.com" +display-name = "Test User" + +# Maildir backend configuration +backend.type = "maildir" +backend.root-dir = "tests/fixtures/test_mailbox" + +# Message configuration +message.send.backend.type = "none" diff --git a/tests/fixtures/test_mailbox/INBOX/cur/1702500005.000005.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/1702500005.000005.test:2,S new file mode 100644 index 0000000..f2a05a8 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/1702500005.000005.test:2,S @@ -0,0 +1,22 @@ +From: Edson Martinez +To: Test User +Subject: DevOps weekly report +Date: Fri, 14 Dec 2025 16:00:00 -0600 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +Hi Team, + +Here's the weekly DevOps report: + +1. Server uptime: 99.9% +2. Deployments this week: 12 +3. Incidents resolved: 3 +4. Pending tasks: 5 + +The CI/CD pipeline improvements are on track for next week. + +Best, +Edson Martinez +DevOps Lead diff --git a/tests/fixtures/test_mailbox/INBOX/cur/1702600004.000004.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/1702600004.000004.test:2,S new file mode 100644 index 0000000..c727316 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/1702600004.000004.test:2,S @@ -0,0 +1,17 @@ +From: Carol Davis +To: Test User +Subject: Re: Budget spreadsheet +Date: Thu, 15 Dec 2025 11:20:00 -0600 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +Hi, + +Thanks for sending over the budget spreadsheet. I've reviewed it and everything looks good. + +One small note: the Q3 numbers need to be updated with the final figures from accounting. + +Let me know once that's done. + +Carol diff --git a/tests/fixtures/test_mailbox/INBOX/cur/1702700003.000003.test:2, b/tests/fixtures/test_mailbox/INBOX/cur/1702700003.000003.test:2, new file mode 100644 index 0000000..1917f43 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/1702700003.000003.test:2, @@ -0,0 +1,19 @@ +From: Bob Williams +To: Test User +Subject: Urgent: Server maintenance tonight +Date: Wed, 16 Dec 2025 18:45:00 -0600 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +URGENT + +The production server will be undergoing maintenance tonight from 11pm to 2am. +Please save all your work before 10:30pm. + +Edson from the DevOps team will be handling the maintenance. + +Contact the IT helpdesk if you have any concerns. + +Bob Williams +IT Department diff --git a/tests/fixtures/test_mailbox/INBOX/cur/1702800002.000002.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/1702800002.000002.test:2,S new file mode 100644 index 0000000..466f213 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/1702800002.000002.test:2,S @@ -0,0 +1,17 @@ +From: Alice Johnson +To: Test User +Subject: Project proposal review +Date: Tue, 17 Dec 2025 14:30:00 -0600 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +Hello, + +I've attached the project proposal for your review. +Please take a look and let me know if you have any questions. + +The deadline for feedback is Friday. + +Thanks, +Alice Johnson diff --git a/tests/fixtures/test_mailbox/INBOX/cur/1702900001.000001.test:2,S b/tests/fixtures/test_mailbox/INBOX/cur/1702900001.000001.test:2,S new file mode 100644 index 0000000..54f2cb9 --- /dev/null +++ b/tests/fixtures/test_mailbox/INBOX/cur/1702900001.000001.test:2,S @@ -0,0 +1,17 @@ +From: John Smith +To: Test User +Subject: Meeting tomorrow at 10am +Date: Mon, 18 Dec 2025 09:00:00 -0600 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 + +Hi Test User, + +Just a reminder about our meeting tomorrow at 10am in the conference room. +We'll be discussing the Q4 budget review. + +Please bring your laptop. + +Best regards, +John Smith diff --git a/tests/test_himalaya_integration.py b/tests/test_himalaya_integration.py new file mode 100644 index 0000000..4ca803a --- /dev/null +++ b/tests/test_himalaya_integration.py @@ -0,0 +1,274 @@ +"""Integration tests for Himalaya client with test mailbox. + +These tests use a local Maildir test mailbox to verify himalaya operations +without touching real email accounts. + +Run with: + pytest tests/test_himalaya_integration.py -v + +Requires: + - himalaya CLI installed + - tests/fixtures/test_mailbox with sample emails + - tests/fixtures/himalaya_test_config.toml +""" + +import asyncio +import json +import os +import subprocess +from pathlib import Path + +import pytest + +# Path to the test config +TEST_CONFIG = Path(__file__).parent / "fixtures" / "himalaya_test_config.toml" +TEST_MAILBOX = Path(__file__).parent / "fixtures" / "test_mailbox" + + +def himalaya_available() -> bool: + """Check if himalaya CLI is installed.""" + try: + result = subprocess.run( + ["himalaya", "--version"], + capture_output=True, + text=True, + ) + return result.returncode == 0 + except FileNotFoundError: + return False + + +# Skip all tests if himalaya is not installed +pytestmark = pytest.mark.skipif( + not himalaya_available(), + reason="himalaya CLI not installed", +) + + +@pytest.fixture +def himalaya_cmd(): + """Return the base himalaya command with test config.""" + # Note: -a must come after the subcommand in himalaya + return f"himalaya -c {TEST_CONFIG}" + + +@pytest.fixture +def account_arg(): + """Return the account argument for himalaya.""" + return "-a test-account" + + +class TestHimalayaListEnvelopes: + """Tests for listing envelopes.""" + + def test_list_all_envelopes(self, himalaya_cmd, account_arg): + """Test listing all envelopes from test mailbox.""" + result = subprocess.run( + f"{himalaya_cmd} envelope list {account_arg} -o json", + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + assert len(envelopes) == 5, f"Expected 5 emails, got {len(envelopes)}" + + def test_envelope_has_required_fields(self, himalaya_cmd, account_arg): + """Test that envelopes have all required fields.""" + result = subprocess.run( + f"{himalaya_cmd} envelope list {account_arg} -o json", + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + envelopes = json.loads(result.stdout) + required_fields = ["id", "subject", "from", "to", "date"] + + for envelope in envelopes: + for field in required_fields: + assert field in envelope, f"Missing field: {field}" + + +class TestHimalayaSearch: + """Tests for search functionality.""" + + def test_search_by_from_name(self, himalaya_cmd, account_arg): + """Test searching by sender name.""" + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + assert len(envelopes) == 1 + assert "Edson" in envelopes[0]["from"]["name"] + + def test_search_by_body_content(self, himalaya_cmd, account_arg): + """Test searching by body content.""" + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "body edson"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + # Should find 2: one from Edson, one mentioning Edson in body + assert len(envelopes) == 2 + + def test_search_by_subject(self, himalaya_cmd, account_arg): + """Test searching by subject.""" + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "subject meeting"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + assert len(envelopes) == 1 + assert "Meeting" in envelopes[0]["subject"] + + def test_search_compound_or_query(self, himalaya_cmd, account_arg): + """Test compound OR search query.""" + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "from edson or body edson"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + # Should find both emails mentioning Edson + assert len(envelopes) >= 2 + + def test_search_no_results(self, himalaya_cmd, account_arg): + """Test search that returns no results.""" + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "from nonexistent_person_xyz"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + assert len(envelopes) == 0 + + def test_search_full_compound_query(self, himalaya_cmd, account_arg): + """Test the full compound query format used by our search function.""" + query = "edson" + search_query = f"from {query} or to {query} or subject {query} or body {query}" + + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "{search_query}"', + shell=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Error: {result.stderr}" + + envelopes = json.loads(result.stdout) + # Should find emails where Edson is sender or mentioned in body + assert len(envelopes) >= 2 + + +class TestHimalayaReadMessage: + """Tests for reading message content.""" + + def test_read_message_by_id(self, himalaya_cmd, account_arg): + """Test reading a message by ID.""" + # First get the list to find an ID + list_result = subprocess.run( + f"{himalaya_cmd} envelope list {account_arg} -o json", + shell=True, + capture_output=True, + text=True, + ) + assert list_result.returncode == 0 + + envelopes = json.loads(list_result.stdout) + message_id = envelopes[0]["id"] + + # Read the message + read_result = subprocess.run( + f"{himalaya_cmd} message read {account_arg} {message_id}", + shell=True, + capture_output=True, + text=True, + ) + assert read_result.returncode == 0, f"Error: {read_result.stderr}" + assert len(read_result.stdout) > 0 + + +class TestHimalayaAsyncClient: + """Tests for the async himalaya client module.""" + + @pytest.mark.asyncio + async def test_search_envelopes_async(self): + """Test the async search_envelopes function.""" + # Import here to avoid issues if himalaya module has import errors + import sys + + # Add project root to path for proper imports + project_root = str(Path(__file__).parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + from src.services.himalaya import client as himalaya_client + + # Note: This test would need the CLI to use our test config + # For now, just verify the function exists and has correct signature + assert hasattr(himalaya_client, "search_envelopes") + assert asyncio.iscoroutinefunction(himalaya_client.search_envelopes) + + +# Additional test for edge cases +class TestSearchEdgeCases: + """Edge case tests for search.""" + + def test_search_with_special_characters(self, himalaya_cmd, account_arg): + """Test searching with special characters in query.""" + # This should not crash, even if no results + result = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "subject Q4"', + shell=True, + capture_output=True, + text=True, + ) + # May fail or succeed depending on himalaya version + # Just verify it doesn't crash catastrophically + assert result.returncode in [0, 1] + + def test_search_case_insensitive(self, himalaya_cmd, account_arg): + """Test that search is case insensitive.""" + result_upper = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "from EDSON"', + shell=True, + capture_output=True, + text=True, + ) + result_lower = subprocess.run( + f'{himalaya_cmd} envelope list {account_arg} -o json "from edson"', + shell=True, + capture_output=True, + text=True, + ) + + # Both should succeed + assert result_upper.returncode == 0 + assert result_lower.returncode == 0 + + # Results should be the same (case insensitive) + upper_envelopes = json.loads(result_upper.stdout) + lower_envelopes = json.loads(result_lower.stdout) + assert len(upper_envelopes) == len(lower_envelopes)