326 lines
11 KiB
Python
326 lines
11 KiB
Python
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", "<accepted@example.local>")
|
|
rejected = _raw_email("blocked@example.com", "<blocked@example.local>")
|
|
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 == "<accepted@example.local>"
|
|
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="<same@example.local>",
|
|
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,
|
|
),
|
|
),
|
|
)
|