Add bounce mailbox processing
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user