Files
handbook/docker/packages/google_mail/tests/test_mailbox_service.py
T
2026-06-24 14:16:58 -07:00

1454 lines
61 KiB
Python

"""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": "<msg1@example.com>",
"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": "<msg2@example.com>",
"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": "<draft1@example.com>",
"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": "<m1@example.com>",
"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": "<m2@example.com>",
"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": "<m3@example.com>",
"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": "<m4@example.com>",
"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": "<m5@example.com>",
"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": "<m6@example.com>",
"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": "<m7@example.com>",
"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