from __future__ import annotations import os from datetime import datetime from email.message import EmailMessage from pathlib import Path import pytest from app.core.config import Settings from app.service.offsite_mail_adapter import ( OffsiteImapReceiver, OffsiteMailStorage, RawMailAttachment, RawMailMessage, ) class FakeImapConnection: def __init__(self, messages: dict[str, bytes] | None = None) -> None: self.messages = messages or {} self.noop_count = 0 self.logout_count = 0 self.selected: list[tuple[str, bool]] = [] self.uid_calls: list[tuple[str, tuple[object, ...]]] = [] def login(self, user: str, password: str) -> tuple[str, list[bytes]]: return "OK", [b"LOGIN completed"] def select(self, mailbox: str, readonly: bool = False) -> tuple[str, list[bytes]]: self.selected.append((mailbox, readonly)) return "OK", [b"2"] def uid(self, command: str, *args: object) -> tuple[str, list[object]]: self.uid_calls.append((command, args)) if command == "search": return "OK", [b" ".join(uid.encode("ascii") for uid in self.messages)] if command == "fetch": uid = str(args[0]) return "OK", [(b"RFC822", self.messages[uid]), b")"] return "NO", [] def noop(self) -> tuple[str, list[bytes]]: self.noop_count += 1 return "OK", [b"NOOP completed"] def logout(self) -> tuple[str, list[bytes]]: self.logout_count += 1 return "BYE", [b"LOGOUT completed"] class FakeClientIdConnection: def __init__(self) -> None: self.commands: list[tuple[str, str]] = [] def _simple_command(self, command: str, arguments: str) -> tuple[str, list[bytes]]: self.commands.append((command, arguments)) return "OK", [b"ID completed"] class FakeIdleConnection(FakeImapConnection): def __init__(self, *, idle_result: bool | Exception) -> None: super().__init__() self.idle_result = idle_result self.idle_calls: list[float] = [] self.idle_done_calls = 0 def idle_start(self) -> object: return "fake-idle" def idle_wait(self, timeout_seconds: float) -> bool: self.idle_calls.append(timeout_seconds) if isinstance(self.idle_result, Exception): raise self.idle_result return self.idle_result def idle_done(self) -> None: self.idle_done_calls += 1 class FakeNativeIdleSocket: def __init__(self) -> None: self.timeout: float | None = None def gettimeout(self) -> float | None: return self.timeout def settimeout(self, timeout: float | None) -> None: self.timeout = timeout class FakeNativeIdleConnection(FakeImapConnection): def __init__(self) -> None: super().__init__() self.sock = FakeNativeIdleSocket() self.sent: list[bytes] = [] self.responses: list[bytes | None] = [None, b"* 3 EXISTS"] self.tagged_tag: object | None = None def send(self, data: bytes) -> object: self.sent.append(data) return None def _new_tag(self) -> bytes: self.tagged_tag = b"A001" return b"A001" def _get_response(self) -> bytes | None: return self.responses.pop(0) def _get_tagged_response(self, tag: object) -> tuple[str, list[bytes]]: assert tag == self.tagged_tag return "OK", [b"IDLE completed"] def test_health_check_returns_disabled_when_imap_switch_is_off() -> None: connection = FakeImapConnection() receiver = OffsiteImapReceiver(_settings(offsite_imap_enabled=False), connection) assert receiver.health_check() == {"status": "disabled", "message": "场外 IMAP 未启用"} assert connection.noop_count == 0 def test_health_check_reports_missing_required_imap_config() -> None: connection = FakeImapConnection() receiver = OffsiteImapReceiver( _settings( offsite_imap_enabled=True, offsite_imap_host="", offsite_imap_username="", offsite_imap_password="", ), connection, ) result = receiver.health_check() assert result["status"] == "misconfigured" assert result["missing"] == [ "OFFSITE_IMAP_HOST", "OFFSITE_IMAP_USERNAME", "OFFSITE_IMAP_PASSWORD", ] assert connection.noop_count == 0 def test_fetch_since_selects_inbox_and_filters_sender_whitelist() -> None: accepted = _raw_email("15008108550@163.com", "") rejected = _raw_email("blocked@example.com", "") connection = FakeImapConnection({"5": accepted, "6": rejected}) receiver = OffsiteImapReceiver(_settings(offsite_imap_enabled=True), connection) messages = receiver.fetch_since("4", limit=20) assert connection.selected == [("INBOX", True)] assert ("search", (None, "UID 5:*")) in connection.uid_calls assert ("fetch", ("5", "(RFC822)")) in connection.uid_calls assert ("fetch", ("6", "(RFC822)")) in connection.uid_calls assert len(messages) == 1 assert messages[0].imap_uid == "5" assert messages[0].message_id == "" assert messages[0].sender == "15008108550@163.com" assert messages[0].return_path == "15008108550@163.com" assert messages[0].attachments[0].filename == "申购申请单.pdf" assert messages[0].auth_result["dkim_signature_present"] is True def test_send_client_id_declares_supported_client_identity() -> None: connection = FakeClientIdConnection() OffsiteImapReceiver._send_client_id(connection) # type: ignore[arg-type] assert connection.commands == [ ( "ID", '("name" "NanfangFund" "version" "1.0" "vendor" "internal")', ) ] def test_fetch_since_rejects_failed_inbox_selection() -> None: class FailedSelectConnection(FakeImapConnection): def select(self, mailbox: str, readonly: bool = False) -> tuple[str, list[bytes]]: self.selected.append((mailbox, readonly)) return "NO", [b"select failed"] receiver = OffsiteImapReceiver( _settings(offsite_imap_enabled=True), FailedSelectConnection(), ) with pytest.raises(RuntimeError, match="选择 INBOX 失败"): receiver.fetch_since("0", limit=3) def test_wait_for_new_mail_returns_true_for_idle_event() -> None: connection = FakeIdleConnection(idle_result=True) receiver = OffsiteImapReceiver( _settings(offsite_imap_enabled=True, offsite_imap_idle_enabled=True), connection, ) assert receiver.wait_for_new_mail(12.5) is True assert connection.selected == [("INBOX", True)] assert connection.idle_calls == [12.5] assert connection.idle_done_calls == 1 def test_wait_for_new_mail_returns_false_after_idle_timeout() -> None: connection = FakeIdleConnection(idle_result=False) receiver = OffsiteImapReceiver( _settings(offsite_imap_enabled=True, offsite_imap_idle_enabled=True), connection, ) assert receiver.wait_for_new_mail(4) is False assert connection.idle_done_calls == 1 def test_wait_for_new_mail_uses_native_imap_idle_commands() -> None: connection = FakeNativeIdleConnection() receiver = OffsiteImapReceiver( _settings(offsite_imap_enabled=True, offsite_imap_idle_enabled=True), connection, ) assert receiver.wait_for_new_mail(8) is True assert connection.sent == [b"A001 IDLE\r\n", b"DONE\r\n"] assert connection.sock.timeout is None def test_wait_for_new_mail_closes_connection_after_idle_disconnect() -> None: connection = FakeIdleConnection(idle_result=ConnectionError("断线")) receiver = OffsiteImapReceiver( _settings(offsite_imap_enabled=True, offsite_imap_idle_enabled=True), connection, ) with pytest.raises(ConnectionError): receiver.wait_for_new_mail(4) assert connection.logout_count == 1 def test_mail_storage_saves_eml_and_attachments_without_overwriting(tmp_path: Path) -> None: storage = OffsiteMailStorage(tmp_path) mail = _raw_mail_message( raw_message=b"From: first@example.com\r\n\r\nfirst", attachment_payload=b"first attachment", ) first = storage.save(mail) second = storage.save( _raw_mail_message( raw_message=b"From: second@example.com\r\n\r\nsecond", attachment_payload=b"second attachment", ) ) eml_path = Path(first.eml_path) first_attachment_path = Path(first.attachments[0].original_file_path) second_attachment_path = Path(second.attachments[0].original_file_path) assert eml_path.read_bytes() == b"From: first@example.com\r\n\r\nfirst" assert first_attachment_path.read_bytes() == b"first attachment" assert second_attachment_path.read_bytes() == b"second attachment" assert first_attachment_path != second_attachment_path assert first.attachments[0].file_hash != second.attachments[0].file_hash for path in (eml_path, first_attachment_path, second_attachment_path): os.chmod(path, 0o666) def _settings(**updates: object) -> Settings: values: dict[str, object] = { "jwt_issuer": "jr-local", "jwt_audience": "jr-agent-platform", "mysql_dsn": "sqlite+aiosqlite:///test.db", "redis_url": "redis://127.0.0.1:6379/0", "milvus_uri": "http://127.0.0.1:19530", "neo4j_uri": "bolt://127.0.0.1:7687", "offsite_imap_host": "imap.example.local", "offsite_imap_username": "15273589815@163.com", "offsite_imap_password": "test-auth-code", "offsite_allowed_senders": ("15008108550@163.com",), } values.update(updates) return Settings(**values) def _raw_email(sender: str, message_id: str) -> bytes: message = EmailMessage() message["From"] = sender message["To"] = "15273589815@163.com" message["Message-ID"] = message_id message["Return-Path"] = sender message["Authentication-Results"] = "mx.example.local; spf=pass" message["Received-SPF"] = "pass" message["DKIM-Signature"] = "v=1; a=rsa-sha256; b=test" message.set_content("场外基金业务邮件") message.add_attachment( b"pdf-bytes", maintype="application", subtype="pdf", filename="申购申请单.pdf", ) return message.as_bytes() def _raw_mail_message(raw_message: bytes, attachment_payload: bytes) -> RawMailMessage: return RawMailMessage( imap_uid="8", message_id="", sender="15008108550@163.com", return_path="15008108550@163.com", auth_result={"spf": "pass"}, raw_message=raw_message, received_at=datetime(2026, 9, 10, 9, 30, 0), attachments=( RawMailAttachment( filename="申购申请单.pdf", media_type="application/pdf", payload=attachment_payload, ), ), )