"""Tests for the MailboxService.""" import json import tempfile from datetime import UTC from pathlib import Path import pytest from pydantic import ValidationError from google_mail.services.mailbox import ( AttachmentNotFoundError, ContactExistsError, ContactNotFoundError, DraftNotFoundError, EmailNotFoundError, FolderExistsError, FolderNotFoundError, MailboxService, RecipientNotFoundError, RecipientRequiredError, ScheduledFolderError, SystemFolderError, _parse_search_query, normalize_search_pagination, search_query_warnings, ) @pytest.fixture def sample_data() -> dict: """Create sample mailbox data.""" return { "mailbox": {"email": "alice@example.com", "name": "Alice Smith"}, "contacts": [ {"email": "bob@example.com", "name": "Bob Jones"}, {"email": "carol@example.com", "name": "Carol White"}, ], "groups": [ { "email": "team@example.com", "name": "Team", "members": ["alice@example.com", "bob@example.com"], }, ], "folders": [{"name": "Work"}], "emails": [ { "email_id": "1", "folder": "INBOX", "subject": "Hello Alice", "from_addr": "bob@example.com", "to_addr": "alice@example.com", "date": "2024-01-15T10:00:00Z", "message_id": "", "body_text": "Hi Alice, how are you?", "is_read": False, "is_important": False, "labels": ["client"], }, { "email_id": "2", "folder": "INBOX", "subject": "Important meeting", "from_addr": "carol@example.com", "to_addr": "alice@example.com", "date": "2024-01-14T09:00:00Z", "message_id": "", "body_text": "Don't forget the meeting tomorrow.", "is_read": True, "is_important": True, "attachments": [ { "filename": "agenda.pdf", "content_type": "application/pdf", "content_base64": "SGVsbG8gV29ybGQ=", }, { "filename": "agenda v2.pdf", "content_type": "application/pdf", "content_base64": "SGVsbG8gV29ybGQ=", }, ], }, { "email_id": "d1", "folder": "Drafts", "subject": "Draft email", "from_addr": "alice@example.com", "to_addr": "bob@example.com", "date": "2024-01-15T08:00:00Z", "message_id": "", "body_text": "This is a draft", "is_read": True, "is_important": False, }, ], "next_email_id": 4, } @pytest.fixture def mailbox_service(sample_data: dict) -> MailboxService: """Create a MailboxService with sample data.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(sample_data, f) temp_path = Path(f.name) service = MailboxService(temp_path) service.load() return service class TestGetEmails: """Tests for get_emails.""" def test_get_all_emails(self, mailbox_service: MailboxService) -> None: """Test getting all emails (excluding drafts).""" emails, total = mailbox_service.get_emails() # 3 total emails, but get_emails excludes Drafts folder by default assert total == 3 assert len(emails) == 3 def test_get_emails_by_folder(self, mailbox_service: MailboxService) -> None: """Test filtering by folder.""" emails, total = mailbox_service.get_emails(folder="INBOX") assert total == 2 emails, total = mailbox_service.get_emails(folder="Sent") assert total == 0 def test_get_emails_invalid_folder(self, mailbox_service: MailboxService) -> None: """Test invalid folder raises error.""" with pytest.raises(FolderNotFoundError): mailbox_service.get_emails(folder="NonExistent") def test_get_emails_pagination(self, mailbox_service: MailboxService) -> None: """Test pagination.""" emails, total = mailbox_service.get_emails(page=1, page_size=1) assert len(emails) == 1 assert total == 3 class TestReadEmail: """Tests for read_email.""" def test_read_email_marks_as_read(self, mailbox_service: MailboxService) -> None: """Test that reading marks email as read.""" email = mailbox_service.read_email("1") assert email.is_read is True def test_read_nonexistent_email(self, mailbox_service: MailboxService) -> None: """Test reading nonexistent email raises error.""" with pytest.raises(EmailNotFoundError): mailbox_service.read_email("999") class TestSearchEmails: """Tests for search_emails.""" @pytest.mark.parametrize( "query,expected_count,expected_subject", [ ("Hello", 1, "Hello Alice"), # search by subject ("meeting", 1, "Important meeting"), # search by body ], ) def test_search_emails( self, mailbox_service: MailboxService, query: str, expected_count: int, expected_subject: str, ) -> None: """Test searching emails by subject and body.""" emails, total = mailbox_service.search_emails(query) assert total == expected_count assert emails[0].subject == expected_subject def test_search_word_and_across_subject_and_body(self, mailbox_service: MailboxService) -> None: # "Hello Alice" hits (both words in subject), "Hello Bob" hits (subject # "Hello Alice", body "Hi Alice" — neither contains "Bob", so this should miss). emails, total = mailbox_service.search_emails("hello alice") assert total == 1 assert emails[0].subject == "Hello Alice" def test_search_misses_when_word_absent(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("hello zzznonexistent") assert total == 0 assert emails == [] def test_search_quoted_phrase_requires_adjacency(self, mailbox_service: MailboxService) -> None: # "Hello Alice" is adjacent in the subject of email 1. emails, total = mailbox_service.search_emails('"hello alice"') assert total == 1 # Reversed — never adjacent anywhere. emails, total = mailbox_service.search_emails('"alice hello"') assert total == 0 def test_search_still_matches_email_address_as_single_token(self, mailbox_service: MailboxService) -> None: # Full email address has no spaces so it's a single token that falls # through to substring semantics naturally. emails, total = mailbox_service.search_emails("bob@example.com") assert total >= 1 def test_search_subject_operator_matches_only_subject(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("subject:meeting") assert total == 1 assert emails[0].email_id == "2" emails, total = mailbox_service.search_emails("subject:tomorrow") assert emails == [] assert total == 0 def test_search_subject_operator_supports_quoted_values(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails('subject:"important meeting"') assert total == 1 assert emails[0].email_id == "2" def test_search_subject_operator_works_with_or(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("subject:hello OR subject:meeting") assert total == 2 assert {email.email_id for email in emails} == {"1", "2"} def test_search_has_attachment_operator(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("has:attachment") assert total == 1 assert emails[0].email_id == "2" def test_search_unknown_has_value_returns_no_results(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("has:drive") assert emails == [] assert total == 0 def test_search_filename_operator(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("filename:agenda.pdf") assert total == 1 assert emails[0].email_id == "2" def test_search_filename_operator_supports_quoted_values(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails('filename:"agenda v2.pdf"') assert total == 1 assert emails[0].email_id == "2" def test_search_is_state_operators(self, mailbox_service: MailboxService) -> None: unread, unread_total = mailbox_service.search_emails("is:unread") assert unread_total == 1 assert unread[0].email_id == "1" read, read_total = mailbox_service.search_emails("is:read") assert read_total == 2 assert {email.email_id for email in read} == {"2", "d1"} important, important_total = mailbox_service.search_emails("is:important") assert important_total == 1 assert important[0].email_id == "2" def test_search_unknown_is_value_returns_no_results(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("is:snoozed") assert emails == [] assert total == 0 def test_search_unknown_is_value_reports_warning(self) -> None: warnings = search_query_warnings("is:snoozed") assert warnings == ["Unsupported is: value 'snoozed'. Supported values: unread, read, important."] def test_search_malformed_date_reports_warning(self) -> None: warnings = search_query_warnings("before:not-a-date invoice") assert warnings == ["Invalid before: date 'not-a-date'. Use YYYY/MM/DD or YYYY-MM-DD."] def test_search_unknown_operator_reports_warning(self) -> None: warnings = search_query_warnings("foo:bar invoice") assert warnings == ["Unsupported Gmail search operator 'foo:'; it will be treated as a text token."] def test_search_negated_state_operators(self, mailbox_service: MailboxService) -> None: no_attachment, no_attachment_total = mailbox_service.search_emails("-has:attachment") assert no_attachment_total == 2 assert {email.email_id for email in no_attachment} == {"1", "d1"} not_unread, not_unread_total = mailbox_service.search_emails("-is:unread") assert not_unread_total == 2 assert {email.email_id for email in not_unread} == {"2", "d1"} def test_search_in_and_label_operators_match_folders(self, mailbox_service: MailboxService) -> None: inbox, inbox_total = mailbox_service.search_emails("in:inbox meeting") assert inbox_total == 1 assert inbox[0].email_id == "2" drafts, drafts_total = mailbox_service.search_emails("label:drafts draft") assert drafts_total == 1 assert drafts[0].email_id == "d1" labels, labels_total = mailbox_service.search_emails("label:client hello") assert labels_total == 1 assert labels[0].email_id == "1" def test_search_normalizes_unsafe_pagination(self, mailbox_service: MailboxService) -> None: page, total = mailbox_service.search_emails("hello", page=0, page_size=-1) assert total == 1 assert page == [] warnings = normalize_search_pagination(page=0, page_size=101)[2] assert warnings == [ "page must be at least 1; using 1.", "page_size exceeds the maximum of 100; using 100.", ] def test_search_new_operators_compose_with_existing_parser(self, mailbox_service: MailboxService) -> None: emails, total = mailbox_service.search_emails("subject:meeting is:important has:attachment -from:bob") assert total == 1 assert emails[0].email_id == "2" def test_search_operator_quoted_values_parse_for_non_subject_fields(self, mailbox_service: MailboxService) -> None: by_sender, sender_total = mailbox_service.search_emails('from:"carol@example.com"') assert sender_total == 1 assert by_sender[0].email_id == "2" by_filename, filename_total = mailbox_service.search_emails('filename:"agenda v2.pdf"') assert filename_total == 1 assert by_filename[0].email_id == "2" def test_parse_search_query_keeps_operator_phrase_groups(self) -> None: clauses = _parse_search_query('from:"carol@example.com" OR filename:"agenda v2.pdf" -has:attachment') assert len(clauses) == 2 assert [(alt.field, alt.value, alt.negated) for alt in clauses[0].alternatives] == [ ("from", "carol@example.com", False), ("filename", "agenda v2.pdf", False), ] assert [(alt.field, alt.value, alt.negated) for alt in clauses[1].alternatives] == [("has", "attachment", True)] class TestSendEmail: """Tests for send_email.""" def test_send_to_valid_recipient(self, mailbox_service: MailboxService) -> None: """Test sending to a valid recipient.""" email = mailbox_service.send_email( to="bob@example.com", subject="Test", body="Test body", ) assert email.folder == "Sent" assert email.to_addr == "bob@example.com" def test_send_to_invalid_recipient(self, mailbox_service: MailboxService) -> None: """Test sending to invalid recipient raises error.""" with pytest.raises(RecipientNotFoundError): mailbox_service.send_email( to="unknown@example.com", subject="Test", body="Test body", ) def test_send_without_recipients_rejects_before_consuming_id(self, mailbox_service: MailboxService) -> None: """Empty active sends fail before constructing mail or advancing counters.""" next_email_id = mailbox_service.data.next_email_id with pytest.raises(RecipientRequiredError): mailbox_service.send_email( to="", subject="Test", body="Test body", ) assert mailbox_service.data.next_email_id == next_email_id def test_send_to_group_creates_inbox_copy(self, mailbox_service: MailboxService) -> None: """Test that sending to a group you're a member of creates inbox copy.""" initial_count = len(mailbox_service.data.emails) mailbox_service.send_email( to="team@example.com", subject="Team message", body="Hello team", ) # Should have 2 new emails: Sent + INBOX copy assert len(mailbox_service.data.emails) == initial_count + 2 def test_send_to_self_lands_in_sent_and_inbox(self, mailbox_service: MailboxService) -> None: """Sending to the mailbox owner's own address creates one copy in Sent and one in INBOX.""" initial = len(mailbox_service.data.emails) sent = mailbox_service.send_email( to="alice@example.com", subject="Note to self", body="Remember the milk", ) assert sent.folder == "Sent" new_emails = mailbox_service.data.emails[initial:] folders = sorted(e.folder for e in new_emails) assert folders == ["INBOX", "Sent"] inbox_copy = next(e for e in new_emails if e.folder == "INBOX") assert inbox_copy.subject == "Note to self" assert inbox_copy.from_addr == "alice@example.com" assert inbox_copy.is_read is False # unread on receipt def test_self_send_does_not_require_contact(self, mailbox_service: MailboxService) -> None: """Contact check is overridden for the owner's own address even if not in contacts.""" # alice@example.com is the owner, not in contacts — still accepted email = mailbox_service.send_email( to="alice@example.com", subject="ok", body="ok", ) assert email.to_addr == "alice@example.com" def test_self_cc_lands_in_inbox(self, mailbox_service: MailboxService) -> None: """CC'ing yourself on an email to someone else also produces an INBOX copy.""" initial = len(mailbox_service.data.emails) mailbox_service.send_email( to="bob@example.com", cc="alice@example.com", subject="loop myself in", body="body", ) new_emails = mailbox_service.data.emails[initial:] assert sorted(e.folder for e in new_emails) == ["INBOX", "Sent"] def test_self_bcc_does_not_land_in_inbox(self, mailbox_service: MailboxService) -> None: """BCC semantics stay honest — self-BCC doesn't create a visible INBOX copy.""" initial = len(mailbox_service.data.emails) mailbox_service.send_email( to="bob@example.com", bcc="alice@example.com", subject="hidden self-bcc", body="body", ) new_emails = mailbox_service.data.emails[initial:] assert len(new_emails) == 1 assert new_emails[0].folder == "Sent" class TestReplyEmail: """Tests for reply_email.""" def test_reply_adds_re_prefix(self, mailbox_service: MailboxService) -> None: """Test that reply adds Re: prefix.""" reply = mailbox_service.reply_email("1", "Thanks!") assert reply.subject == "Re: Hello Alice" def test_reply_to_nonexistent(self, mailbox_service: MailboxService) -> None: """Test reply to nonexistent email raises error.""" with pytest.raises(EmailNotFoundError): mailbox_service.reply_email("999", "Thanks!") class TestForwardEmail: """Tests for forward_email.""" def test_forward_adds_fwd_prefix(self, mailbox_service: MailboxService) -> None: """Test that forward adds Fwd: prefix.""" fwd = mailbox_service.forward_email("1", "carol@example.com") assert fwd.subject == "Fwd: Hello Alice" def test_forward_to_multiple_recipients(self, mailbox_service: MailboxService) -> None: """Test forwarding to multiple comma-separated recipients.""" fwd = mailbox_service.forward_email("1", "bob@example.com, carol@example.com") assert fwd.subject == "Fwd: Hello Alice" assert fwd.to_addr == "bob@example.com, carol@example.com" def test_forward_to_mixed_valid_invalid_recipients(self, mailbox_service: MailboxService) -> None: """Test forwarding to a mix of valid and invalid recipients raises error.""" with pytest.raises(RecipientNotFoundError): mailbox_service.forward_email("1", "bob@example.com, unknown@example.com") class TestDeleteEmail: """Tests for delete_email.""" def test_delete_moves_to_trash(self, mailbox_service: MailboxService) -> None: """Test that delete moves to Trash by default.""" mailbox_service.delete_email("1") email = mailbox_service.get_email("1") assert email.folder == "Trash" def test_delete_permanent(self, mailbox_service: MailboxService) -> None: """Test permanent deletion.""" mailbox_service.delete_email("1", permanent=True) with pytest.raises(EmailNotFoundError): mailbox_service.get_email("1") def test_delete_emails_batch_saves_once( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch ) -> None: """Test batch delete performs all mutations before one persistence pass.""" save_calls = 0 original_save = mailbox_service._save def counting_save() -> None: nonlocal save_calls save_calls += 1 original_save() monkeypatch.setattr(mailbox_service, "_save", counting_save) result = mailbox_service.delete_emails(["1", "2", "missing"]) assert result.succeeded_ids == ["1", "2"] assert [error.email_id for error in result.errors] == ["missing"] assert save_calls == 1 def test_delete_emails_batch_rolls_back_if_save_fails( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test failed batch persistence restores in-memory delete mutations.""" monkeypatch.setattr(mailbox_service, "_save", lambda: (_ for _ in ()).throw(RuntimeError("save failed"))) with pytest.raises(RuntimeError, match="save failed"): mailbox_service.delete_emails(["1", "2"]) assert mailbox_service.get_email("1").folder == "INBOX" assert mailbox_service.get_email("2").folder == "INBOX" def test_delete_empty_recipient_draft_moves_to_trash(self, mailbox_service: MailboxService) -> None: """Test soft-deleting an incomplete draft keeps persisted state valid.""" draft = mailbox_service.save_draft(subject="No recipient yet", body="Draft content") mailbox_service.delete_email(draft.email_id) trashed = mailbox_service.get_email(draft.email_id) assert trashed.folder == "Trash" assert trashed.to_addr == "" class TestMoveEmail: """Tests for move_email.""" def test_move_emails_batch_saves_once( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch ) -> None: """Test batch move performs all mutations before one persistence pass.""" save_calls = 0 original_save = mailbox_service._save def counting_save() -> None: nonlocal save_calls save_calls += 1 original_save() monkeypatch.setattr(mailbox_service, "_save", counting_save) result = mailbox_service.move_emails(["1", "2", "missing"], "Work") assert result.succeeded_ids == ["1", "2"] assert [error.email_id for error in result.errors] == ["missing"] assert mailbox_service.get_email("1").folder == "Work" assert mailbox_service.get_email("2").folder == "Work" assert save_calls == 1 def test_move_emails_batch_invalid_folder_does_not_save( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test invalid batch move target reports every attempt without persisting.""" save_calls = 0 def counting_save() -> None: nonlocal save_calls save_calls += 1 monkeypatch.setattr(mailbox_service, "_save", counting_save) result = mailbox_service.move_emails(["1", "missing"], "Nope") assert result.succeeded_ids == [] assert [error.email_id for error in result.errors] == ["1", "missing"] assert all(error.error == "Folder not found: Nope" for error in result.errors) assert save_calls == 0 def test_move_emails_batch_rolls_back_if_save_fails( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test failed batch persistence restores in-memory move mutations.""" monkeypatch.setattr(mailbox_service, "_save", lambda: (_ for _ in ()).throw(RuntimeError("save failed"))) with pytest.raises(RuntimeError, match="save failed"): mailbox_service.move_emails(["1", "2"], "Work") assert mailbox_service.get_email("1").folder == "INBOX" assert mailbox_service.get_email("2").folder == "INBOX" def test_move_empty_recipient_draft_to_trash(self, mailbox_service: MailboxService) -> None: """Test moving an incomplete draft to Trash keeps persisted state valid.""" draft = mailbox_service.save_draft(subject="No recipient yet", body="Draft content") mailbox_service.move_email(draft.email_id, "Trash") trashed = mailbox_service.get_email(draft.email_id) assert trashed.folder == "Trash" assert trashed.to_addr == "" def test_move_empty_recipient_draft_to_active_folder_requires_recipient( self, mailbox_service: MailboxService, ) -> None: """Test incomplete drafts cannot become active messages without recipients.""" draft = mailbox_service.save_draft(subject="No recipient yet", body="Draft content") with pytest.raises(RecipientRequiredError): mailbox_service.move_email(draft.email_id, "Work") def test_move_cc_only_draft_to_active_folder(self, mailbox_service: MailboxService) -> None: """CC-only drafts count as recipient-bearing active messages.""" draft = mailbox_service.save_draft(subject="CC only", body="Draft content", cc="alice@example.com") mailbox_service.move_email(draft.email_id, "Work") moved = mailbox_service.get_email(draft.email_id) assert moved.folder == "Work" assert moved.to_addr == "" assert moved.cc_addr == "alice@example.com" def test_move_emails_reports_empty_recipient_draft_errors(self, mailbox_service: MailboxService) -> None: """Test batch move reports incomplete-draft errors without aborting valid moves.""" draft = mailbox_service.save_draft(subject="No recipient yet", body="Draft content") result = mailbox_service.move_emails(["1", draft.email_id], "Work") assert result.succeeded_ids == ["1"] assert [error.email_id for error in result.errors] == [draft.email_id] assert "Cannot move email without recipients" in result.errors[0].error assert mailbox_service.get_email(draft.email_id).folder == "Drafts" class TestMarkEmails: """Tests for mark_emails.""" def test_mark_emails_batch_reports_missing_ids_and_saves_once( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test service-level batch marking owns missing-id detection.""" save_calls = 0 original_save = mailbox_service._save def counting_save() -> None: nonlocal save_calls save_calls += 1 original_save() monkeypatch.setattr(mailbox_service, "_save", counting_save) result = mailbox_service.mark_emails(["1", "missing"], is_read=True, is_important=True) assert result.succeeded_ids == ["1"] assert [error.email_id for error in result.errors] == ["missing"] assert mailbox_service.get_email("1").is_read is True assert mailbox_service.get_email("1").is_important is True assert save_calls == 1 def test_mark_emails_requires_action(self, mailbox_service: MailboxService) -> None: """Test direct service callers cannot perform a silent no-op mark.""" with pytest.raises(ValueError, match="At least one"): mailbox_service.mark_emails(["1"]) def test_mark_emails_batch_rolls_back_if_save_fails( self, mailbox_service: MailboxService, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test failed batch persistence restores in-memory mark mutations.""" monkeypatch.setattr(mailbox_service, "_save", lambda: (_ for _ in ()).throw(RuntimeError("save failed"))) with pytest.raises(RuntimeError, match="save failed"): mailbox_service.mark_emails(["1"], is_read=True, is_important=True) email = mailbox_service.get_email("1") assert email.is_read is False assert email.is_important is False class TestFolders: """Tests for folder operations.""" def test_folder_crud_operations(self, mailbox_service: MailboxService) -> None: """Test folder create, read, delete operations.""" # Test get folders folders = mailbox_service.get_folders() folder_names = [f["name"] for f in folders] assert "INBOX" in folder_names assert "Sent" in folder_names assert "Work" in folder_names # Test create folder mailbox_service.create_folder("Personal") folders = mailbox_service.get_folders() folder_names = [f["name"] for f in folders] assert "Personal" in folder_names # Test delete folder mailbox_service.delete_folder("Work") folders = mailbox_service.get_folders() folder_names = [f["name"] for f in folders] assert "Work" not in folder_names def test_folder_error_handling(self, mailbox_service: MailboxService) -> None: """Test folder error cases.""" # Test creating duplicate folder with pytest.raises(FolderExistsError): mailbox_service.create_folder("Work") # Test deleting system folder with pytest.raises(SystemFolderError): mailbox_service.delete_folder("INBOX") class TestDrafts: """Tests for draft operations.""" def test_save_draft(self, mailbox_service: MailboxService) -> None: """Test saving a draft.""" draft = mailbox_service.save_draft( subject="New draft", body="Draft content", to="bob@example.com", ) assert draft.subject == "New draft" assert draft.body_text == "Draft content" assert draft.folder == "Drafts" def test_save_draft_without_recipient(self, mailbox_service: MailboxService) -> None: """Test saving an incomplete draft with no recipient.""" draft = mailbox_service.save_draft( subject="No recipient yet", body="Draft content", ) assert draft.folder == "Drafts" assert draft.to_addr == "" def test_get_drafts(self, mailbox_service: MailboxService) -> None: """Test getting all drafts.""" drafts, total = mailbox_service.get_drafts() assert total == 1 def test_update_draft(self, mailbox_service: MailboxService) -> None: """Test updating a draft.""" draft = mailbox_service.update_draft("d1", subject="Updated subject") assert draft.subject == "Updated subject" def test_update_draft_rolls_back_invalid_recipient(self, mailbox_service: MailboxService) -> None: """Invalid draft updates must not corrupt the live in-memory draft.""" original = mailbox_service.get_draft("d1").model_dump(mode="json") with pytest.raises(ValidationError): mailbox_service.update_draft("d1", subject="Should roll back", to="not an email") restored = mailbox_service.get_draft("d1") assert restored.model_dump(mode="json") == original updated = mailbox_service.update_draft("d1", subject="Valid update") assert updated.subject == "Valid update" def test_delete_draft(self, mailbox_service: MailboxService) -> None: """Test deleting a draft.""" mailbox_service.delete_draft("d1") with pytest.raises(DraftNotFoundError): mailbox_service.get_draft("d1") class TestContacts: """Tests for contact operations.""" def test_get_contacts(self, mailbox_service: MailboxService) -> None: """Test getting all contacts.""" contacts = mailbox_service.get_contacts() assert len(contacts) == 2 def test_contacts_sorted_by_name(self, mailbox_service: MailboxService) -> None: """Test that contacts are sorted by name.""" contacts = mailbox_service.get_contacts() names = [c.name for c in contacts] assert names == sorted(names, key=str.lower) def test_get_groups(self, mailbox_service: MailboxService) -> None: """Test getting contact groups.""" groups = mailbox_service.get_groups() assert len(groups) == 1 assert groups[0].email == "team@example.com" assert groups[0].members == ["alice@example.com", "bob@example.com"] def test_search_contacts_by_name(self, mailbox_service: MailboxService) -> None: """Test searching contacts by name.""" contacts = mailbox_service.search_contacts("bob") assert len(contacts) == 1 assert contacts[0].email == "bob@example.com" def test_search_contacts_by_email(self, mailbox_service: MailboxService) -> None: """Test searching contacts by email.""" contacts = mailbox_service.search_contacts("carol@") assert len(contacts) == 1 assert contacts[0].name == "Carol White" def test_search_contacts_case_insensitive(self, mailbox_service: MailboxService) -> None: """Test that search is case-insensitive.""" contacts = mailbox_service.search_contacts("BOB") assert len(contacts) == 1 def test_search_contacts_no_match(self, mailbox_service: MailboxService) -> None: """Test search with no matches returns empty list.""" contacts = mailbox_service.search_contacts("nonexistent") assert contacts == [] def test_add_contact(self, mailbox_service: MailboxService) -> None: """Test adding a new contact.""" contact = mailbox_service.add_contact("dave@example.com", "Dave Brown") assert contact.email == "dave@example.com" assert contact.name == "Dave Brown" # Verify it's in the list all_contacts = mailbox_service.get_contacts() assert len(all_contacts) == 3 def test_add_contact_duplicate(self, mailbox_service: MailboxService) -> None: """Test adding a contact with duplicate email raises error.""" with pytest.raises(ContactExistsError): mailbox_service.add_contact("bob@example.com", "Bob Duplicate") def test_add_group(self, mailbox_service: MailboxService) -> None: """Test adding a group with members.""" group = mailbox_service.add_group( "devs@example.com", "Developers", ["alice@example.com", "bob@example.com"], ) assert group.email == "devs@example.com" assert group.members == ["alice@example.com", "bob@example.com"] def test_add_group_rolls_back_invalid_members(self, mailbox_service: MailboxService) -> None: """Test invalid group creation does not leave invalid state in memory.""" with pytest.raises(ValueError): mailbox_service.add_group("bad@example.com", "Bad Group", ["unknown@example.com"]) assert mailbox_service.data.get_group_by_email("bad@example.com") is None def test_edit_contact_name(self, mailbox_service: MailboxService) -> None: """Test updating a contact's name.""" contact = mailbox_service.edit_contact("bob@example.com", name="Robert Jones") assert contact.name == "Robert Jones" assert contact.email == "bob@example.com" def test_edit_group_members(self, mailbox_service: MailboxService) -> None: """Test updating a group's members.""" group = mailbox_service.edit_group("team@example.com", members=["bob@example.com"]) assert group.members == ["bob@example.com"] def test_edit_group_rolls_back_invalid_members(self, mailbox_service: MailboxService) -> None: """Test invalid group edits do not leave invalid state in memory.""" with pytest.raises(ValueError): mailbox_service.edit_group("team@example.com", members=["unknown@example.com"]) group = mailbox_service.data.get_group_by_email("team@example.com") assert group is not None assert group.members == [ "alice@example.com", "bob@example.com", ] def test_edit_contact_not_found(self, mailbox_service: MailboxService) -> None: """Test editing nonexistent contact raises error.""" with pytest.raises(ContactNotFoundError): mailbox_service.edit_contact("nobody@example.com", name="Nobody") def test_delete_contact(self, mailbox_service: MailboxService) -> None: """Test deleting a contact.""" mailbox_service.delete_contact("carol@example.com") all_contacts = mailbox_service.get_contacts() assert len(all_contacts) == 1 assert all(c.email != "carol@example.com" for c in all_contacts) def test_delete_contact_not_found(self, mailbox_service: MailboxService) -> None: """Test deleting nonexistent contact raises error.""" with pytest.raises(ContactNotFoundError): mailbox_service.delete_contact("nobody@example.com") class TestScheduledEmails: """Tests for email scheduling operations.""" def test_schedule_email(self, mailbox_service: MailboxService) -> None: """Test scheduling an email for later delivery.""" from datetime import datetime send_at = datetime(2025, 6, 1, 9, 0, 0, tzinfo=UTC) email = mailbox_service.schedule_email( to="bob@example.com", subject="Scheduled test", body="This is scheduled", scheduled_time=send_at, ) assert email.folder == "Scheduled" assert email.scheduled_time == send_at assert email.to_addr == "bob@example.com" def test_schedule_email_invalid_recipient(self, mailbox_service: MailboxService) -> None: """Test scheduling to invalid recipient raises error.""" from datetime import datetime send_at = datetime(2025, 6, 1, 9, 0, 0, tzinfo=UTC) with pytest.raises(RecipientNotFoundError): mailbox_service.schedule_email( to="nobody@example.com", subject="Test", body="Test", scheduled_time=send_at, ) def test_schedule_email_without_recipients_rejects_before_consuming_id( self, mailbox_service: MailboxService ) -> None: """Empty scheduled sends fail before constructing mail or advancing counters.""" from datetime import datetime next_email_id = mailbox_service.data.next_email_id send_at = datetime(2025, 6, 1, 9, 0, 0, tzinfo=UTC) with pytest.raises(RecipientRequiredError): mailbox_service.schedule_email( to="", subject="Test", body="Test", scheduled_time=send_at, ) assert mailbox_service.data.next_email_id == next_email_id def test_get_scheduled_emails(self, mailbox_service: MailboxService) -> None: """Test listing scheduled emails.""" from datetime import datetime mailbox_service.schedule_email( to="bob@example.com", subject="First", body="1", scheduled_time=datetime(2025, 6, 2, tzinfo=UTC), ) mailbox_service.schedule_email( to="carol@example.com", subject="Second", body="2", scheduled_time=datetime(2025, 6, 1, tzinfo=UTC), ) emails, total = mailbox_service.get_scheduled_emails() assert total == 2 # Sorted by scheduled_time ascending assert emails[0].subject == "Second" assert emails[1].subject == "First" def test_get_scheduled_emails_empty(self, mailbox_service: MailboxService) -> None: """Test listing when no emails are scheduled.""" emails, total = mailbox_service.get_scheduled_emails() assert total == 0 assert emails == [] def test_cancel_scheduled_email(self, mailbox_service: MailboxService) -> None: """Test cancelling a scheduled email.""" from datetime import datetime email = mailbox_service.schedule_email( to="bob@example.com", subject="Cancel me", body="test", scheduled_time=datetime(2025, 6, 1, tzinfo=UTC), ) mailbox_service.cancel_scheduled_email(email.email_id) emails, total = mailbox_service.get_scheduled_emails() assert total == 0 def test_cancel_nonexistent_email(self, mailbox_service: MailboxService) -> None: """Test cancelling non-existent email raises error.""" with pytest.raises(EmailNotFoundError): mailbox_service.cancel_scheduled_email("999") def test_cancel_non_scheduled_email(self, mailbox_service: MailboxService) -> None: """Test cancelling a regular (non-scheduled) email raises error.""" with pytest.raises(EmailNotFoundError): mailbox_service.cancel_scheduled_email("1") # existing INBOX email def test_delete_scheduled_email_moves_to_trash_and_clears_scheduled_time( self, mailbox_service: MailboxService, ) -> None: """Test soft-deleting scheduled mail keeps persisted state valid.""" from datetime import datetime email = mailbox_service.schedule_email( to="bob@example.com", subject="Delete scheduled", body="test", scheduled_time=datetime(2025, 6, 1, tzinfo=UTC), ) mailbox_service.delete_email(email.email_id) trashed = mailbox_service.get_email(email.email_id) assert trashed.folder == "Trash" assert trashed.scheduled_time is None def test_move_scheduled_email_clears_scheduled_time(self, mailbox_service: MailboxService) -> None: """Test moving scheduled mail out of Scheduled keeps persisted state valid.""" from datetime import datetime email = mailbox_service.schedule_email( to="bob@example.com", subject="Move scheduled", body="test", scheduled_time=datetime(2025, 6, 1, tzinfo=UTC), ) mailbox_service.move_email(email.email_id, "Work") moved = mailbox_service.get_email(email.email_id) assert moved.folder == "Work" assert moved.scheduled_time is None def test_move_regular_email_to_scheduled_requires_schedule_tool(self, mailbox_service: MailboxService) -> None: """Test regular mail cannot enter Scheduled without scheduled_time metadata.""" with pytest.raises(ScheduledFolderError): mailbox_service.move_email("1", "Scheduled") class TestAttachments: """Tests for attachment operations.""" def test_get_attachment(self, mailbox_service: MailboxService) -> None: """Test getting an attachment.""" attachment = mailbox_service.get_attachment("2", "agenda.pdf") assert attachment.filename == "agenda.pdf" assert attachment.content_type == "application/pdf" def test_get_nonexistent_attachment(self, mailbox_service: MailboxService) -> None: """Test getting nonexistent attachment raises error.""" with pytest.raises(AttachmentNotFoundError): mailbox_service.get_attachment("2", "nonexistent.pdf") # --------------------------------------------------------------------------- # Gmail-style operator tests # --------------------------------------------------------------------------- @pytest.fixture def operator_mailbox(tmp_path: Path) -> MailboxService: """Mailbox seeded for Gmail operator coverage. Dates are computed relative to "now" so newer_than/older_than are stable. """ from datetime import UTC, datetime, timedelta now = datetime.now(UTC) def ts(delta_days: int) -> str: return (now - timedelta(days=delta_days)).isoformat() data = { "mailbox": {"email": "agent@example.com", "name": "Agent"}, "contacts": [ {"email": "alice@example.com", "name": "Alice"}, {"email": "bob@example.com", "name": "Bob"}, {"email": "carol@example.com", "name": "Carol"}, {"email": "dave@example.com", "name": "Dave"}, {"email": "spammer@example.com", "name": "Spammer"}, ], "folders": [], "emails": [ { "email_id": "1", "folder": "INBOX", "subject": "Invoice for March hours", "from_addr": "alice@example.com", "to_addr": "agent@example.com", "cc_addr": None, "bcc_addr": None, "date": ts(2), "message_id": "", "body_text": "Here is your invoice for March billing.", "is_read": False, "is_important": True, "attachments": [], }, { "email_id": "2", "folder": "INBOX", "subject": "Billing update", "from_addr": "bob@example.com", "to_addr": "agent@example.com", "cc_addr": "alice@example.com", "bcc_addr": None, "date": ts(5), "message_id": "", "body_text": "Quarterly billing summary attached.", "is_read": True, "is_important": False, "attachments": [], }, { "email_id": "3", "folder": "INBOX", "subject": "Contract draft", "from_addr": "carol@example.com", "to_addr": "agent@example.com", "cc_addr": None, "bcc_addr": "dave@example.com", "date": ts(20), "message_id": "", "body_text": "Please review the draft contract by Friday.", "is_read": True, "is_important": False, "attachments": [], }, { "email_id": "4", "folder": "Sent", "subject": "Re: scheduling", "from_addr": "agent@example.com", "to_addr": "alice@example.com", "cc_addr": None, "bcc_addr": None, "date": ts(10), "message_id": "", "body_text": "Works for me.", "is_read": True, "is_important": False, "attachments": [], }, { "email_id": "5", "folder": "INBOX", "subject": "Spam offer", "from_addr": "spammer@example.com", "to_addr": "agent@example.com", "cc_addr": None, "bcc_addr": None, "date": ts(1), "message_id": "", "body_text": "Amazing deal on contract forms.", "is_read": False, "is_important": False, "attachments": [], }, { "email_id": "6", "folder": "INBOX", "subject": "Ancient newsletter", "from_addr": "newsletter@example.com", "to_addr": "agent@example.com", "cc_addr": None, "bcc_addr": None, "date": ts(500), # ~1.3 years ago "message_id": "", "body_text": "Old newsletter content here.", "is_read": True, "is_important": False, "attachments": [], }, { "email_id": "7", "folder": "INBOX", "subject": "March summary", "from_addr": "alice@example.com", "to_addr": "bob@example.com", "cc_addr": "agent@example.com", "bcc_addr": None, "date": ts(35), "message_id": "", "body_text": "Summary of March activity.", "is_read": True, "is_important": False, "attachments": [], }, ], "next_email_id": 8, } # Add a contact for the newsletter sender so validation passes. data["contacts"].append({"email": "newsletter@example.com", "name": "Newsletter"}) path = tmp_path / "mailbox.json" path.write_text(json.dumps(data)) svc = MailboxService(path) svc.load() return svc class TestSearchEmailsOperators: """Cover every Gmail operator + parser edge case.""" # ---- Address filters -------------------------------------------------- def test_from_substring_match(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("from:alice") assert total == 2 assert {e.email_id for e in emails} == {"1", "7"} def test_from_case_insensitive_operator_name(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("FROM:alice") assert total == 2 def test_from_does_not_match_body_text(self, operator_mailbox: MailboxService) -> None: # "agent" appears in bodies/to_addr but not in any from_addr emails, total = operator_mailbox.search_emails("from:agent") # Only email 4 is FROM agent@ assert total == 1 assert emails[0].email_id == "4" def test_to_matches_only_to_field(self, operator_mailbox: MailboxService) -> None: # email 7 has to=bob, cc=agent — `to:agent` should NOT match email 7 emails, total = operator_mailbox.search_emails("to:bob") assert total == 1 assert emails[0].email_id == "7" def test_cc_matches_cc_field(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("cc:alice") assert {e.email_id for e in emails} == {"2"} assert total == 1 def test_bcc_matches_bcc_field(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("bcc:dave") assert {e.email_id for e in emails} == {"3"} def test_address_operator_empty_value_returns_nothing(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("from:") assert total == 0 # ---- OR --------------------------------------------------------------- def test_or_between_bare_words(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("invoice OR newsletter") # email 1 (invoice), email 6 (newsletter) ids = {e.email_id for e in emails} assert "1" in ids and "6" in ids def test_or_between_two_operators(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("from:alice OR from:bob") assert {e.email_id for e in emails} == {"1", "2", "7"} def test_or_across_operator_and_bare_word(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("from:alice OR contract") # from:alice → 1,7; bare 'contract' → 3, 5 (body mentions 'contract') assert {e.email_id for e in emails} == {"1", "3", "5", "7"} def test_lowercase_or_is_literal(self, operator_mailbox: MailboxService) -> None: # Lowercase 'or' must be treated as a literal bare-word token. # None of the emails contain 'or' as a standalone word in matched regions; # fixture wording ("for", "works", etc.) contains 'or' as substring though. emails_literal, _ = operator_mailbox.search_emails("invoice or billing") emails_boolean, _ = operator_mailbox.search_emails("invoice OR billing") # Boolean version matches at least as many as literal version. assert len(emails_boolean) >= len(emails_literal) # Boolean version includes email 1 (invoice) AND email 2 (billing). assert {"1", "2"}.issubset({e.email_id for e in emails_boolean}) def test_trailing_or_dangles_gracefully(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("invoice OR") # Should behave like just "invoice" assert any(e.email_id == "1" for e in emails) assert total >= 1 def test_leading_or_dangles_gracefully(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("OR invoice") assert any(e.email_id == "1" for e in emails) def test_chained_or(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("invoice OR newsletter OR contract") ids = {e.email_id for e in emails} # 1 invoice, 3 contract (subject), 5 contract (body), 6 newsletter assert {"1", "3", "5", "6"}.issubset(ids) # ---- Negation --------------------------------------------------------- def test_negation_excludes_bare_word(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("march -spam") # 'march' in subject of 1, 7; 'spam' appears in subject of 5 only. # 5 doesn't contain 'march' anyway, so it's already excluded. # Exclude any message containing 'spam'. ids = {e.email_id for e in emails} assert "5" not in ids assert "1" in ids def test_negation_of_from(self, operator_mailbox: MailboxService) -> None: # Match messages NOT from alice that mention 'billing' or 'march'. emails, total = operator_mailbox.search_emails("billing -from:alice") ids = {e.email_id for e in emails} assert "2" in ids assert "1" not in ids # alice is sender of 1 assert "7" not in ids # alice sender def test_negation_of_quoted_phrase(self, operator_mailbox: MailboxService) -> None: # Every message that does NOT contain the exact phrase 'march hours'. emails, total = operator_mailbox.search_emails('-"march hours"') ids = {e.email_id for e in emails} # Only email 1 has 'March hours' adjacent in the subject. assert "1" not in ids def test_lone_dash_dropped(self, operator_mailbox: MailboxService) -> None: # "- invoice" — the lone "-" must be ignored; behaves like "invoice". emails, total = operator_mailbox.search_emails("- invoice") assert any(e.email_id == "1" for e in emails) def test_double_dash_treated_as_part_of_value(self, operator_mailbox: MailboxService) -> None: # "--invoice" → negates, value becomes "-invoice" which doesn't match anywhere. emails, total = operator_mailbox.search_emails("--invoice") # Nothing contains literal "-invoice" so the negation passes for all emails. assert total == len(operator_mailbox.data.emails) # ---- Phrases + word-AND preservation ---------------------------------- def test_quoted_phrase_still_requires_adjacency(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails('"march hours"') assert {e.email_id for e in emails} == {"1"} # Reversed phrase should NOT match emails, total = operator_mailbox.search_emails('"hours march"') assert total == 0 def test_operator_combined_with_phrase(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails('from:alice "march hours"') assert {e.email_id for e in emails} == {"1"} def test_bare_multi_word_is_AND(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("march billing") # both words must appear. email 1: 'March' subject + 'March billing' body → yes. # email 7: subject 'March summary', body 'March activity' → no 'billing'. assert {e.email_id for e in emails} == {"1"} def test_operator_with_quoted_value(self, operator_mailbox: MailboxService) -> None: # Quoted value after operator is a phrase-style substring match. emails, total = operator_mailbox.search_emails('from:"alice@example.com"') assert {e.email_id for e in emails} == {"1", "7"} # ---- Dates ------------------------------------------------------------ def test_before_is_strict(self, operator_mailbox: MailboxService) -> None: from datetime import UTC, datetime, timedelta # Use a date so exactly email 1 (2 days ago) is excluded by strict before. now = datetime.now(UTC) day_1 = (now - timedelta(days=2)).strftime("%Y/%m/%d") emails, total = operator_mailbox.search_emails(f"before:{day_1}") ids = {e.email_id for e in emails} # email 1 dated exactly day_1 should NOT be included (strict before). # Emails older than day_1 (2,3,4,6,7) should be included. assert "1" not in ids or not any(e.date.strftime("%Y/%m/%d") == day_1 for e in emails if e.email_id == "1") def test_after_is_inclusive(self, operator_mailbox: MailboxService) -> None: from datetime import UTC, datetime, timedelta now = datetime.now(UTC) day_2 = (now - timedelta(days=2)).strftime("%Y/%m/%d") emails, _ = operator_mailbox.search_emails(f"after:{day_2}") ids = {e.email_id for e in emails} # email 1 (2 days ago) and 5 (1 day ago) should both be included. assert "1" in ids and "5" in ids def test_date_dash_separator(self, operator_mailbox: MailboxService) -> None: from datetime import UTC, datetime, timedelta now = datetime.now(UTC) day_3 = (now - timedelta(days=3)).strftime("%Y-%m-%d") emails, _ = operator_mailbox.search_emails(f"after:{day_3}") assert any(e.email_id == "1" for e in emails) def test_date_range_intersection(self, operator_mailbox: MailboxService) -> None: from datetime import UTC, datetime, timedelta now = datetime.now(UTC) after = (now - timedelta(days=7)).strftime("%Y/%m/%d") before = (now - timedelta(days=1)).strftime("%Y/%m/%d") emails, _ = operator_mailbox.search_emails(f"after:{after} before:{before}") # Emails in [7 days ago, 1 day ago) — that's email 1 (2 days), email 2 (5 days) ids = {e.email_id for e in emails} assert "1" in ids and "2" in ids assert "6" not in ids # 500 days ago def test_malformed_date_fails_silently(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("before:garbage") assert total == 0 def test_newer_than_days(self, operator_mailbox: MailboxService) -> None: emails, _ = operator_mailbox.search_emails("newer_than:7d") ids = {e.email_id for e in emails} # Emails within last 7 days: 1 (2d), 2 (5d), 5 (1d) assert {"1", "2", "5"}.issubset(ids) assert "6" not in ids # 500 days old def test_newer_than_months(self, operator_mailbox: MailboxService) -> None: emails, _ = operator_mailbox.search_emails("newer_than:1m") # 30 days: 1, 2, 4, 5 (all within 30 days), plus maybe 3 (20 days) ids = {e.email_id for e in emails} assert "3" in ids # 20 days ago assert "6" not in ids # 500 days ago def test_newer_than_years(self, operator_mailbox: MailboxService) -> None: emails, _ = operator_mailbox.search_emails("newer_than:2y") ids = {e.email_id for e in emails} assert "6" in ids # 500 days, within 2*365 def test_older_than_years(self, operator_mailbox: MailboxService) -> None: emails, _ = operator_mailbox.search_emails("older_than:1y") ids = {e.email_id for e in emails} # Only email 6 (~500 days) is older than 1 year. assert ids == {"6"} def test_malformed_duration_fails_silently(self, operator_mailbox: MailboxService) -> None: assert operator_mailbox.search_emails("newer_than:7x")[1] == 0 assert operator_mailbox.search_emails("newer_than:foo")[1] == 0 # ---- Parser combinatorics -------------------------------------------- def test_empty_query_returns_nothing(self, operator_mailbox: MailboxService) -> None: emails, total = operator_mailbox.search_emails("") assert emails == [] and total == 0 def test_whitespace_query_returns_nothing(self, operator_mailbox: MailboxService) -> None: assert operator_mailbox.search_emails(" ")[1] == 0 def test_only_operators_no_bare_words(self, operator_mailbox: MailboxService) -> None: from datetime import UTC, datetime, timedelta now = datetime.now(UTC) after = (now - timedelta(days=7)).strftime("%Y/%m/%d") emails, _ = operator_mailbox.search_emails(f"from:alice after:{after}") # Only email 1 — alice sent within 7 days. assert {e.email_id for e in emails} == {"1"} def test_only_negation(self, operator_mailbox: MailboxService) -> None: # Returns every email that does NOT contain 'spam'. emails, total = operator_mailbox.search_emails("-spam") assert total > 0 assert "5" not in {e.email_id for e in emails} def test_unknown_operator_treated_as_literal_bare_word(self, operator_mailbox: MailboxService) -> None: # 'random:foo' matches if that literal substring appears anywhere. emails, total = operator_mailbox.search_emails("random:foo") assert total == 0 # ---- Integration with existing args ---------------------------------- def test_folder_filter_still_applies(self, operator_mailbox: MailboxService) -> None: emails, _ = operator_mailbox.search_emails("from:alice", folder="Sent") # email 4 is in Sent (from agent, to alice) — NOT from alice. # In INBOX + Sent only email 4 is in Sent and it's from agent. assert {e.email_id for e in emails} == set() # no alice-sent mail in Sent def test_pagination_with_operator(self, operator_mailbox: MailboxService) -> None: # Get all mentioning 'march' (subject or body), paginate. page1, total = operator_mailbox.search_emails("march", page=1, page_size=1) page2, _ = operator_mailbox.search_emails("march", page=2, page_size=1) assert total >= 2 assert len(page1) == 1 and len(page2) == 1 assert page1[0].email_id != page2[0].email_id