441 lines
14 KiB
Python
441 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
from datetime import UTC, datetime
|
|
import poplib
|
|
import ssl
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from govoplan_access.backend.db.models import Account, User
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.security.secrets import decrypt_secret
|
|
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
|
|
from govoplan_mail.backend.db.models import (
|
|
MailPop3Import,
|
|
MailServerEndpoint,
|
|
MailServerProfile,
|
|
)
|
|
from govoplan_mail.backend.pop3_imports import (
|
|
Pop3ImportResult,
|
|
create_pop3_imports,
|
|
list_pop3_imports,
|
|
)
|
|
from govoplan_mail.backend.router import import_profile_pop3_messages
|
|
from govoplan_mail.backend.schemas import MailPop3ImportRequest
|
|
from govoplan_mail.backend.sending.pop3 import (
|
|
Pop3ConfigurationError,
|
|
Pop3DownloadedMessage,
|
|
Pop3MessageSummary,
|
|
Pop3ProviderError,
|
|
_open_pop3,
|
|
delete_pop3_messages,
|
|
download_pop3_messages,
|
|
preview_pop3_messages,
|
|
)
|
|
|
|
|
|
_RAW = (
|
|
b"Subject: Legacy notice\r\n"
|
|
b"From: Office <office@example.test>\r\n"
|
|
b"To: Subject <subject@example.test>\r\n"
|
|
b"Message-ID: <legacy-1@example.test>\r\n"
|
|
b"\r\n"
|
|
b"A bounded legacy message.\r\n"
|
|
)
|
|
|
|
|
|
class _Pop3Client:
|
|
def __init__(self, *, quit_error: Exception | None = None) -> None:
|
|
self.deletions: list[int] = []
|
|
self.quit_calls = 0
|
|
self.rset_calls = 0
|
|
self.close_calls = 0
|
|
self.quit_error = quit_error
|
|
|
|
def stat(self):
|
|
return 1, len(_RAW)
|
|
|
|
def uidl(self):
|
|
return b"+OK", [b"1 uid-1"], 1
|
|
|
|
def list(self):
|
|
return b"+OK", [f"1 {len(_RAW)}".encode("ascii")], 1
|
|
|
|
def top(self, _number, _lines):
|
|
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
|
|
|
|
def retr(self, _number):
|
|
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
|
|
|
|
def dele(self, number):
|
|
self.deletions.append(number)
|
|
|
|
def quit(self):
|
|
self.quit_calls += 1
|
|
if self.quit_error is not None:
|
|
raise self.quit_error
|
|
return b"+OK"
|
|
|
|
def rset(self):
|
|
self.rset_calls += 1
|
|
|
|
def close(self):
|
|
self.close_calls += 1
|
|
|
|
|
|
def _config(**changes) -> Pop3Config:
|
|
values = {
|
|
"host": "pop3.example.test",
|
|
"security": "tls",
|
|
"username": "legacy-user",
|
|
"password": "legacy-password",
|
|
"legacy_import_enabled": True,
|
|
}
|
|
values.update(changes)
|
|
return Pop3Config.model_validate(values)
|
|
|
|
|
|
def _download(uidl: str = "uid-1") -> Pop3DownloadedMessage:
|
|
summary = Pop3MessageSummary(
|
|
message_number=1,
|
|
uidl=uidl,
|
|
subject="Legacy notice",
|
|
from_header="Office <office@example.test>",
|
|
to_header="Subject <subject@example.test>",
|
|
date="Sat, 22 Aug 2026 10:00:00 +0200",
|
|
message_id="<legacy-1@example.test>",
|
|
size_bytes=len(_RAW),
|
|
body_preview="A bounded legacy message.",
|
|
)
|
|
import hashlib
|
|
|
|
return Pop3DownloadedMessage(
|
|
message_number=1,
|
|
uidl=uidl,
|
|
raw=_RAW,
|
|
raw_sha256=hashlib.sha256(_RAW).hexdigest(),
|
|
summary=summary,
|
|
)
|
|
|
|
|
|
class Pop3TransportTests(unittest.TestCase):
|
|
def test_legacy_import_is_disabled_until_explicitly_enabled(self) -> None:
|
|
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
|
|
preview_pop3_messages(
|
|
pop3_config=_config(legacy_import_enabled=False),
|
|
limit=10,
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "batch size limit"):
|
|
_config(max_message_bytes=2 * 1024 * 1024, max_batch_bytes=1024 * 1024)
|
|
|
|
def test_preview_and_download_are_non_destructive(self) -> None:
|
|
preview_client = _Pop3Client()
|
|
with patch(
|
|
"govoplan_mail.backend.sending.pop3._open_pop3",
|
|
return_value=preview_client,
|
|
):
|
|
preview = preview_pop3_messages(pop3_config=_config(), limit=10)
|
|
|
|
self.assertEqual(["uid-1"], [item.uidl for item in preview.messages])
|
|
self.assertEqual([], preview_client.deletions)
|
|
self.assertEqual(1, preview_client.quit_calls)
|
|
|
|
download_client = _Pop3Client()
|
|
with patch(
|
|
"govoplan_mail.backend.sending.pop3._open_pop3",
|
|
return_value=download_client,
|
|
):
|
|
downloaded = download_pop3_messages(
|
|
pop3_config=_config(), uidls=("uid-1",)
|
|
)
|
|
|
|
self.assertEqual(_RAW, downloaded[0].raw)
|
|
self.assertEqual([], download_client.deletions)
|
|
self.assertEqual(1, download_client.quit_calls)
|
|
|
|
def test_source_deletion_needs_policy_and_commits_with_quit(self) -> None:
|
|
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
|
|
delete_pop3_messages(pop3_config=_config(), uidls=("uid-1",))
|
|
|
|
client = _Pop3Client()
|
|
with patch(
|
|
"govoplan_mail.backend.sending.pop3._open_pop3", return_value=client
|
|
):
|
|
result = delete_pop3_messages(
|
|
pop3_config=_config(allow_delete_after_import=True),
|
|
uidls=("uid-1",),
|
|
)
|
|
|
|
self.assertEqual(("uid-1",), result.deleted_uidls)
|
|
self.assertEqual([1], client.deletions)
|
|
self.assertEqual(1, client.quit_calls)
|
|
|
|
def test_quit_failure_marks_deletion_outcome_unknown(self) -> None:
|
|
client = _Pop3Client(quit_error=poplib.error_proto("connection lost"))
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.sending.pop3._open_pop3",
|
|
return_value=client,
|
|
),
|
|
self.assertRaises(Pop3ProviderError) as captured,
|
|
):
|
|
delete_pop3_messages(
|
|
pop3_config=_config(allow_delete_after_import=True),
|
|
uidls=("uid-1",),
|
|
)
|
|
|
|
self.assertTrue(captured.exception.outcome_unknown)
|
|
self.assertEqual([1], client.deletions)
|
|
|
|
def test_tls_and_authentication_failures_are_sanitized(self) -> None:
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
|
|
),
|
|
patch(
|
|
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3SSL",
|
|
side_effect=ssl.SSLError("private TLS detail"),
|
|
),
|
|
self.assertRaisesRegex(Pop3ProviderError, "TLS negotiation failed"),
|
|
):
|
|
_open_pop3(_config())
|
|
|
|
auth_client = _Pop3Client()
|
|
auth_client.user = lambda _value: None # type: ignore[attr-defined]
|
|
auth_client.pass_ = lambda _value: (_ for _ in ()).throw( # type: ignore[attr-defined]
|
|
poplib.error_proto("private auth detail")
|
|
)
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
|
|
),
|
|
patch(
|
|
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3",
|
|
return_value=auth_client,
|
|
),
|
|
self.assertRaisesRegex(Pop3ProviderError, "authentication failed"),
|
|
):
|
|
_open_pop3(_config(security=TransportSecurity.PLAIN))
|
|
|
|
|
|
class Pop3PersistenceTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=(
|
|
Account.__table__,
|
|
User.__table__,
|
|
MailServerProfile.__table__,
|
|
MailServerEndpoint.__table__,
|
|
MailPop3Import.__table__,
|
|
),
|
|
)
|
|
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
|
self.profile = MailServerProfile(
|
|
id="profile-1",
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
name="Legacy source",
|
|
slug="legacy-source",
|
|
smtp_config={},
|
|
)
|
|
self.server = MailServerEndpoint(
|
|
id="server-1",
|
|
profile_id=self.profile.id,
|
|
tenant_id="tenant-1",
|
|
protocol="pop3",
|
|
name="Legacy POP3",
|
|
config={"legacy_import_enabled": True},
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
transport_revision="revision-1",
|
|
)
|
|
self.session.add_all((self.profile, self.server))
|
|
self.session.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
self.engine.dispose()
|
|
|
|
def test_import_is_encrypted_and_duplicate_uidl_is_reused(self) -> None:
|
|
first = create_pop3_imports(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
profile_id=self.profile.id,
|
|
pop3_server_id=self.server.id,
|
|
pop3_credential_id=None,
|
|
transport_revision="revision-1",
|
|
messages=(_download(),),
|
|
user_id=None,
|
|
deletion_requested=False,
|
|
)
|
|
self.session.commit()
|
|
|
|
self.assertEqual(1, len(first.imported))
|
|
encrypted = first.imported[0].raw_message_encrypted
|
|
self.assertNotIn("Legacy notice", encrypted)
|
|
self.assertEqual(
|
|
_RAW,
|
|
base64.b64decode(decrypt_secret(encrypted) or ""),
|
|
)
|
|
self.assertEqual("not_requested", first.imported[0].deletion_status)
|
|
|
|
repeated = create_pop3_imports(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
profile_id=self.profile.id,
|
|
pop3_server_id=self.server.id,
|
|
pop3_credential_id=None,
|
|
transport_revision="revision-1",
|
|
messages=(_download(),),
|
|
user_id=None,
|
|
deletion_requested=False,
|
|
)
|
|
|
|
self.assertEqual((), repeated.imported)
|
|
self.assertEqual((first.imported[0].id,), tuple(row.id for row in repeated.duplicates))
|
|
self.assertEqual(
|
|
(first.imported[0].id,),
|
|
tuple(
|
|
row.id
|
|
for row in list_pop3_imports(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
profile_ids=(self.profile.id,),
|
|
)
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
(),
|
|
list_pop3_imports(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
profile_ids=("unrelated-profile",),
|
|
),
|
|
)
|
|
|
|
|
|
class _RouteSession:
|
|
def __init__(self, events: list[str]) -> None:
|
|
self.events = events
|
|
|
|
def commit(self) -> None:
|
|
self.events.append("commit")
|
|
|
|
def rollback(self) -> None:
|
|
self.events.append("rollback")
|
|
|
|
|
|
class Pop3ImportRouteTests(unittest.TestCase):
|
|
def test_local_import_and_audit_commit_before_source_deletion(self) -> None:
|
|
events: list[str] = []
|
|
now = datetime.now(UTC)
|
|
row = SimpleNamespace(
|
|
id="import-1",
|
|
profile_id="profile-1",
|
|
pop3_server_id="server-1",
|
|
transport_revision="revision-1",
|
|
provider_uidl="uid-1",
|
|
message_id="<legacy-1@example.test>",
|
|
subject="Legacy notice",
|
|
from_header="office@example.test",
|
|
to_header="subject@example.test",
|
|
date="2026-08-22",
|
|
body_preview="A bounded legacy message.",
|
|
size_bytes=len(_RAW),
|
|
raw_sha256=_download().raw_sha256,
|
|
status="pending_review",
|
|
imported_at=now,
|
|
deletion_requested=True,
|
|
deletion_status="pending",
|
|
deletion_attempted_at=None,
|
|
deletion_error=None,
|
|
)
|
|
resolved = SimpleNamespace(
|
|
config=_config(allow_delete_after_import=True),
|
|
server=SimpleNamespace(id="server-1"),
|
|
credential=None,
|
|
transport_revision="revision-1",
|
|
)
|
|
principal = ApiPrincipal(
|
|
principal=PrincipalRef(
|
|
account_id="account-1",
|
|
membership_id="user-1",
|
|
tenant_id="tenant-1",
|
|
scopes=frozenset(
|
|
{
|
|
"mail:profile:use",
|
|
"mail:pop3:import",
|
|
"mail:pop3:delete",
|
|
}
|
|
),
|
|
),
|
|
account=SimpleNamespace(id="account-1"),
|
|
user=SimpleNamespace(id="user-1"),
|
|
)
|
|
payload = MailPop3ImportRequest(
|
|
server_id="server-1",
|
|
expected_transport_revision="revision-1",
|
|
uidls=["uid-1"],
|
|
delete_after_import=True,
|
|
)
|
|
|
|
def audit(*_args, **_kwargs) -> None:
|
|
events.append("audit")
|
|
|
|
def delete(**_kwargs):
|
|
self.assertEqual(["audit", "commit"], events)
|
|
events.append("delete")
|
|
|
|
def mark(*_args, **_kwargs):
|
|
events.append("mark")
|
|
row.deletion_status = "succeeded"
|
|
row.deletion_attempted_at = now
|
|
return (row,)
|
|
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.router._resolve_profile_pop3_transport",
|
|
return_value=(SimpleNamespace(id="profile-1"), resolved),
|
|
),
|
|
patch(
|
|
"govoplan_mail.backend.router.download_pop3_messages",
|
|
return_value=(_download(),),
|
|
),
|
|
patch(
|
|
"govoplan_mail.backend.router.create_pop3_imports",
|
|
return_value=Pop3ImportResult(imported=(row,), duplicates=()),
|
|
),
|
|
patch("govoplan_mail.backend.router.audit_event", side_effect=audit),
|
|
patch("govoplan_mail.backend.router.delete_pop3_messages", side_effect=delete),
|
|
patch(
|
|
"govoplan_mail.backend.router.mark_pop3_deletion_result",
|
|
side_effect=mark,
|
|
),
|
|
):
|
|
result = import_profile_pop3_messages(
|
|
"profile-1",
|
|
payload,
|
|
principal=principal,
|
|
session=_RouteSession(events), # type: ignore[arg-type]
|
|
)
|
|
|
|
self.assertEqual("succeeded", result.deletion_status)
|
|
self.assertEqual(
|
|
["audit", "commit", "delete", "mark", "commit", "audit", "commit"],
|
|
events,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|