187 lines
6.0 KiB
Python
187 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import Column, String, Table, create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_mail.backend.bounce_processing import (
|
|
SqlMailBounceProcessingProvider,
|
|
parse_delivery_status,
|
|
)
|
|
from govoplan_mail.backend.db.models import (
|
|
MailBounceObservation,
|
|
MailBounceSource,
|
|
MailDeliveryCommand,
|
|
MailServerProfile,
|
|
)
|
|
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
|
|
|
|
|
|
DSN = b"""From: Mail Delivery Subsystem <mailer-daemon@example.test>
|
|
To: sender@example.test
|
|
Date: Fri, 31 Jul 2026 12:00:00 +0000
|
|
Subject: Delivery Status Notification (Failure)
|
|
MIME-Version: 1.0
|
|
Content-Type: multipart/report; report-type=delivery-status; boundary="dsn"
|
|
|
|
--dsn
|
|
Content-Type: text/plain; charset=utf-8
|
|
|
|
Delivery failed.
|
|
--dsn
|
|
Content-Type: message/delivery-status
|
|
|
|
Reporting-MTA: dns; mx.example.test
|
|
Original-Message-ID: <outgoing-1@example.test>
|
|
|
|
Final-Recipient: rfc822; recipient@example.test
|
|
Action: failed
|
|
Status: 5.1.1
|
|
Remote-MTA: dns; destination.example.test
|
|
Diagnostic-Code: smtp; 550 mailbox unavailable
|
|
Last-Attempt-Date: Fri, 31 Jul 2026 11:59:00 +0000
|
|
|
|
--dsn
|
|
Content-Type: message/rfc822
|
|
|
|
Message-ID: <outgoing-1@example.test>
|
|
From: sender@example.test
|
|
To: recipient@example.test
|
|
Subject: Original
|
|
|
|
Body
|
|
--dsn--
|
|
"""
|
|
|
|
|
|
class MailBounceProcessingTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine(
|
|
"sqlite+pysqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
access_users = Base.metadata.tables.get("access_users")
|
|
if access_users is None:
|
|
access_users = Table(
|
|
"access_users",
|
|
Base.metadata,
|
|
Column("id", String(36), primary_key=True),
|
|
)
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
access_users,
|
|
MailServerProfile.__table__,
|
|
MailDeliveryCommand.__table__,
|
|
MailBounceSource.__table__,
|
|
MailBounceObservation.__table__,
|
|
],
|
|
)
|
|
self.SessionLocal = sessionmaker(
|
|
bind=self.engine,
|
|
class_=Session,
|
|
expire_on_commit=False,
|
|
)
|
|
with self.SessionLocal() as session:
|
|
session.add(
|
|
MailServerProfile(
|
|
id="profile-1",
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
name="Delivery",
|
|
slug="delivery",
|
|
smtp_config={"host": "smtp.example.test", "port": 25},
|
|
)
|
|
)
|
|
session.commit()
|
|
self.audit_delivery = patch(
|
|
"govoplan_mail.backend.delivery_outbox.audit_event"
|
|
)
|
|
self.audit_bounce = patch(
|
|
"govoplan_mail.backend.bounce_processing.audit_event"
|
|
)
|
|
self.audit_delivery.start()
|
|
self.audit_bounce.start()
|
|
self.addCleanup(self.audit_delivery.stop)
|
|
self.addCleanup(self.audit_bounce.stop)
|
|
self.addCleanup(self.engine.dispose)
|
|
|
|
def test_parser_extracts_structured_recipient_outcome(self) -> None:
|
|
reports = parse_delivery_status(DSN)
|
|
|
|
self.assertEqual(1, len(reports))
|
|
self.assertEqual("recipient@example.test", reports[0]["recipient"])
|
|
self.assertEqual("failed", reports[0]["action"])
|
|
self.assertEqual("5.1.1", reports[0]["status_code"])
|
|
self.assertEqual(
|
|
"<outgoing-1@example.test>",
|
|
reports[0]["original_message_id"],
|
|
)
|
|
|
|
def test_processing_correlates_and_is_idempotent(self) -> None:
|
|
provider = SqlMailBounceProcessingProvider()
|
|
with self.SessionLocal() as session:
|
|
command = submit_delivery_command(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
command_type="campaign_report",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign",
|
|
source_resource_id="campaign-1",
|
|
source_version_id="version-1",
|
|
idempotency_key="delivery-1",
|
|
profile_id="profile-1",
|
|
message_bytes=(
|
|
b"Message-ID: <outgoing-1@example.test>\r\n"
|
|
b"Subject: Original\r\n\r\nBody"
|
|
),
|
|
envelope_from="sender@example.test",
|
|
envelope_recipients=["recipient@example.test"],
|
|
from_header="sender@example.test",
|
|
expected_smtp_transport_revision="revision-1",
|
|
)
|
|
first = provider.process_raw_message(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
profile_id="profile-1",
|
|
folder="INBOX",
|
|
uid="42",
|
|
raw_message=DSN,
|
|
)
|
|
second = provider.process_raw_message(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
profile_id="profile-1",
|
|
folder="INBOX",
|
|
uid="42",
|
|
raw_message=DSN,
|
|
)
|
|
|
|
self.assertEqual(command["id"], first[0].command_id)
|
|
self.assertTrue(first[0].matched)
|
|
self.assertTrue(first[0].permanent)
|
|
self.assertEqual(first[0].id, second[0].id)
|
|
self.assertEqual(1, session.query(MailBounceObservation).count())
|
|
self.assertEqual(
|
|
"pending",
|
|
session.get(MailDeliveryCommand, command["id"]).status,
|
|
)
|
|
|
|
def test_ordinary_mail_is_not_misclassified(self) -> None:
|
|
self.assertEqual(
|
|
(),
|
|
parse_delivery_status(
|
|
b"From: person@example.test\r\nSubject: Hello\r\n\r\nNot a DSN"
|
|
),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|