"""Date refinement through the public MCP contract, without live mailboxes.""" import asyncio from datetime import datetime import re import sqlite3 import pytest from mcp_servers import email_server as es @pytest.fixture def indexed_mailbox(monkeypatch, tmp_path): monkeypatch.setenv('ODYSSEUS_EMAIL_FIXTURE', '0') monkeypatch.setenv('EMAIL_CACHE_DB', str(tmp_path / 'missing-cache.db')) monkeypatch.setattr(es, '_ACCOUNT_CACHE', {}) account_defaults = dict.fromkeys(( 'imap_host', 'imap_port', 'imap_password', 'imap_starttls', 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_user', 'smtp_password', 'from_address'), '') monkeypatch.setattr(es, '_read_accounts_from_db', lambda: [ {**account_defaults, 'id': 'account-a', 'owner': 'alice', 'name': 'Test', 'imap_user': 'alice@example.invalid'}, {**account_defaults, 'id': 'account-b', 'owner': 'bob', 'name': 'Other', 'imap_user': 'bob@example.invalid'}, ]) db = tmp_path / 'index.db' monkeypatch.setattr(es, 'SCHEDULED_EMAILS_DB', str(db)) with sqlite3.connect(db) as conn: conn.execute('''CREATE TABLE email_message_index ( owner TEXT, account_key TEXT, folder TEXT, uid TEXT, message_id TEXT, subject TEXT, from_name TEXT, from_address TEXT, to_text TEXT, cc_text TEXT, date_iso TEXT, date_display TEXT, date_epoch REAL)''') for owner, account, uid, date in [ ('alice', 'account-a', '10', '2026-06-30T23:59:59+00:00'), ('alice', 'account-a', '20', '2026-07-01T00:00:00+00:00'), ('alice', 'account-a', '30', '2026-08-01T00:00:00+00:00'), ('bob', 'account-b', '99', '2026-07-02T00:00:00+00:00'), ]: conn.execute('INSERT INTO email_message_index VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)', (owner, account, 'INBOX', uid, f'<{uid}@example.invalid>', 'Fixture receipt', 'Fixture', 'fixture@example.invalid', '', '', date, date, datetime.fromisoformat(date).timestamp())) def no_network(*args, **kwargs): raise AssertionError('This index test must not access a real mailbox') monkeypatch.setattr(es, '_imap_connect', no_network) return db def search(**kwargs): result = asyncio.run(es.call_tool('search_emails', { '_odysseus_owner': 'alice', 'query': 'receipt', 'folders': ['INBOX'], 'max_results': 1, **kwargs, })) return '\n'.join(item.text for item in result) def uids(text): return re.findall(r'^\s*UID:\s*(\S+)', text, re.M) def test_list_date_refinement_filters_imap_before_limit(indexed_mailbox, monkeypatch): monkeypatch.setattr(es, 'SCHEDULED_EMAILS_DB', indexed_mailbox.with_name('missing.db')) dates = {b'10': 'Tue, 30 Jun 2026 23:59:59 +0000', b'20': 'Wed, 01 Jul 2026 09:00:00 +0900', b'30': 'Sat, 01 Aug 2026 09:00:00 +0900'} class Mailbox: def select(self, folder, readonly=False): assert readonly return 'OK', [] def uid(self, operation, *args): if operation == 'SEARCH': return 'OK', [b'10 20 30'] assert operation == 'FETCH' return 'OK', [(b'1 (UID ' + uid + b' RFC822.HEADER {100}', f'Subject: Fixture receipt\r\nFrom: fixture@example.invalid\r\nDate: {dates[uid]}\r\n\r\n'.encode()) for uid in args[0].split(b',')] def logout(self): pass monkeypatch.setattr(es, '_imap_connect', lambda account=None: Mailbox()) result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'account': 'account-a', 'max_results': 1, 'date_from': '2026-07-01T00:00:00Z', 'date_to': '2026-08-01T00:00:00Z', })) text = '\n'.join(item.text for item in result) assert uids(text) == ['20'], text def test_list_date_refinement_pages_headers_and_preserves_unread_filter(indexed_mailbox, monkeypatch): monkeypatch.setattr(es, 'SCHEDULED_EMAILS_DB', indexed_mailbox.with_name('missing.db')) batches = [] searches = [] class Mailbox: def select(self, folder, readonly=False): assert readonly return 'OK', [] def uid(self, operation, *args): if operation == 'SEARCH': searches.append(args[1]) return 'OK', [b' '.join(str(n).encode() for n in range(1, 121))] assert operation == 'FETCH' ids = args[0].split(b',') batches.append(ids) return 'OK', [(b'1 (UID ' + uid + b' RFC822.HEADER {100}', ('Subject: Fixture\r\nDate: ' + ('unknown' if int(uid) > 110 else 'Sat, 01 Aug 2026 09:00:00 +0900' if int(uid) > 90 else 'Wed, 01 Jul 2026 09:00:00 +0900') + '\r\n\r\n').encode()) for uid in ids] def logout(self): pass monkeypatch.setattr(es, '_imap_connect', lambda account=None: Mailbox()) result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'account': 'account-a', 'max_results': 60, 'unread_only': True, 'date_from': '2026-07-01', 'date_to': '2026-08-01', })) text = '\n'.join(item.text for item in result) assert uids(text) == [str(n) for n in range(90, 30, -1)], text assert max(map(len, batches)) <= 50 assert len(batches) == 2, 'Stop fetching once enough matching rows have been found' assert 'UNSEEN' in searches[0] assert 'SENTSINCE' in searches[0] and 'SENTBEFORE' in searches[0] def test_indexed_listing_uses_utc_for_timezone_less_bounds(indexed_mailbox, monkeypatch): import time if not hasattr(time, 'tzset'): pytest.skip('Process timezone test requires tzset') with sqlite3.connect(indexed_mailbox) as conn: conn.execute('ALTER TABLE email_message_index ADD COLUMN attachment_names TEXT') try: with monkeypatch.context() as env: env.setenv('TZ', 'Pacific/Honolulu') time.tzset() result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'account': 'account-a', 'max_results': 1, 'date_from': '2026-07-01', 'date_to': '2026-08-01', })) finally: time.tzset() text = '\n'.join(item.text for item in result) assert uids(text) == ['20'], text @pytest.mark.parametrize('bounds', [ {'date_from': 'not-a-date'}, {'date_to': '2026-99-99'}, {'date_from': '2026-08-01', 'date_to': '2026-07-01'}, {'date_from': '2026-07-01', 'date_to': '2026-07-01'}, ]) def test_invalid_list_bounds_do_not_broaden_to_the_whole_mailbox(indexed_mailbox, bounds): result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'account': 'account-a', **bounds, })) text = '\n'.join(item.text for item in result) assert text.startswith('Error: date_'), text assert not uids(text) def test_invalid_multi_account_list_bounds_are_rejected_before_scanning(indexed_mailbox, monkeypatch): accounts = es._read_accounts_from_db() monkeypatch.setattr(es, '_read_accounts_from_db', lambda: [ {**row, 'owner': 'alice'} for row in accounts ]) result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'date_from': 'not-a-date', })) text = '\n'.join(item.text for item in result) assert text.startswith('Error: date_from'), text assert not uids(text) def test_date_filtered_listing_reuses_indexed_headers_after_imap_search(indexed_mailbox, monkeypatch): with sqlite3.connect(indexed_mailbox) as conn: conn.execute('ALTER TABLE email_message_index ADD COLUMN attachment_names TEXT') # The index lacks flags, so it cannot establish unread state. Only the live # IMAP search can select unread UIDs; their cached headers can still be reused. class Mailbox: def select(self, folder, readonly=False): assert readonly return 'OK', [] def uid(self, operation, *args): assert operation == 'SEARCH', 'Already indexed headers must not be fetched again' assert 'UNSEEN' in args[1] return 'OK', [b'10 20 30'] def logout(self): pass monkeypatch.setattr(es, '_imap_connect', lambda account=None: Mailbox()) result = asyncio.run(es.call_tool('list_emails', { '_odysseus_owner': 'alice', 'account': 'account-a', 'unread_only': True, 'max_results': 1, 'date_from': '2026-07-01', 'date_to': '2026-08-01', })) text = '\n'.join(item.text for item in result) assert uids(text) == ['20'], text def test_search_date_refinement_filters_index_before_limit(indexed_mailbox): broad = search() assert uids(broad) == ['30'], broad assert uids(search(date_from='2026-07-01', date_to='2026-08-01')) == ['20'] @pytest.mark.parametrize('folder', ['INBOX', 'Archive']) def test_missing_email_read_identifies_the_selected_folder(indexed_mailbox, monkeypatch, folder): class Mailbox: def select(self, selected, readonly=False): assert readonly assert folder in selected return 'OK', [] def uid(self, operation, *args): assert operation == 'FETCH' assert args[1] == '(BODY.PEEK[])' return 'OK', [None] def logout(self): pass monkeypatch.setattr(es, '_imap_connect', lambda account=None: Mailbox()) result = asyncio.run(es.call_tool('read_email', { '_odysseus_owner': 'alice', 'uid': '20', 'folder': folder, })) text = '\n'.join(item.text for item in result) assert 'Error: Email not found with UID 20' in text assert f'in folder {folder}' in text assert 'UIDs are folder-specific' in text @pytest.mark.parametrize('bounds,expected', [ ({'date_from': '2026-07-01'}, ['30', '20']), ({'date_to': '2026-08-01'}, ['20', '10']), ({'date_from': '2026-07-01T09:00:00+09:00', 'date_to': '2026-07-02T09:00:00+09:00'}, ['20']), ]) def test_index_search_bounds_preserve_exact_instants_and_owner(indexed_mailbox, bounds, expected): assert uids(search(max_results=10, **bounds)) == expected @pytest.mark.parametrize('bounds', [ {'date_from': 'not-a-date'}, {'date_to': '2026-99-99'}, {'date_from': '2026-08-01', 'date_to': '2026-07-01'}, {'date_from': '2026-07-01', 'date_to': '2026-07-01'}, ]) def test_invalid_search_bounds_fail_instead_of_silently_broadening(indexed_mailbox, bounds): result = search(**bounds) assert result.startswith('Error: Search failed: date_'), result assert not uids(result) def test_search_date_refinement_filters_imap_headers_before_limit(indexed_mailbox, monkeypatch): monkeypatch.setattr(es, 'SCHEDULED_EMAILS_DB', indexed_mailbox.with_name('missing.db')) dates = {b'10': 'Tue, 30 Jun 2026 23:59:59 +0000', b'20': 'Wed, 01 Jul 2026 09:00:00 +0900', b'30': 'Sat, 01 Aug 2026 09:00:00 +0900', b'40': 'unknown'} class Mailbox: def select(self, folder, readonly=False): assert readonly, 'search must not mark mail read' return 'OK', [] def uid(self, operation, *args): if operation == 'SEARCH': assert 'SENTSINCE' in args[1] and 'SENTBEFORE' in args[1] return 'OK', [b'10 20 30 40'] assert operation == 'FETCH' uid = args[0] header = f'Subject: Fixture receipt\r\nFrom: fixture@example.invalid\r\nDate: {dates[uid]}\r\n\r\n' return 'OK', [(b'header', header.encode())] def logout(self): pass monkeypatch.setattr(es, '_imap_connect', lambda account=None: Mailbox()) result = search(date_from='2026-07-01T00:00:00Z', date_to='2026-08-01T00:00:00Z') assert uids(result) == ['20'], result