"""Tests for the core.mda.outbound module."""
# pylint: disable=unused-argument,too-many-lines

import re
import threading
import time
from unittest.mock import MagicMock, call, patch

from django.conf import settings
from django.core.cache import cache
from django.test import TransactionTestCase, override_settings

import dns.resolver
import pytest
import rest_framework as drf
from dkim import verify as dkim_verify

from core import enums, factories, models
from core.mda import outbound
from core.mda.outbound_direct import resolve_hostname_ip, send_message_via_mx
from core.mda.signing import generate_dkim_key, sign_message_dkim
from core.mda.smtp import SmtpProxy

SCHEMA_CUSTOM_ATTRIBUTES = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://github.com/suitenumerique/messages/schemas/custom-fields/user",
    "type": "object",
    "title": "User custom fields",
    "additionalProperties": False,
    "properties": {
        "job_title": {"type": "string"},
        "department": {"type": "string"},
    },
    "required": [],
}

# Module-level DKIM keypair (1024-bit for speed) used by the signing tests.
_TEST_DKIM_PRIVATE_KEY, _TEST_DKIM_PUBLIC_KEY = generate_dkim_key(key_size=1024)


@pytest.fixture(name="user")
def fixture_user():
    """Create a test user with custom attributes."""
    return factories.UserFactory(
        full_name="John Doe",
        custom_attributes={
            "job_title": "Software Engineer",
            "department": "Engineering",
        },
    )


@pytest.fixture(name="signature_template")
def fixture_signature_template(mailbox_sender):
    """Create a signature template in the same mailbox as the sender."""
    return factories.MessageTemplateFactory(
        name="Professional Signature",
        html_body="<p>Best regards,<br>{name}<br>{job_title}<br>{department}</p>",
        text_body="Best regards,\n{name}\n{job_title}\n{department}",
        type=enums.MessageTemplateTypeChoices.SIGNATURE,
        is_active=True,
        mailbox=mailbox_sender,
    )


@pytest.fixture(name="mailbox_sender")
def fixture_mailbox_sender():
    """Create a test mailbox sender."""
    return factories.MailboxFactory()


@pytest.fixture(name="mailbox_access")
def fixture_mailbox_access(user, mailbox_sender):
    """Create mailbox access for the user."""
    return factories.MailboxAccessFactory(
        user=user,
        mailbox=mailbox_sender,
        role=models.MailboxRoleChoices.EDITOR,
    )


@pytest.fixture(name="other_user")
def fixture_other_user():
    """Create another user for testing unauthorized access."""
    return factories.UserFactory()


@pytest.fixture(name="message")
def fixture_message(mailbox_sender, signature_template=None):
    """Create a test message."""
    thread = factories.ThreadFactory()
    factories.ThreadAccessFactory(
        mailbox=mailbox_sender,
        thread=thread,
        role=enums.ThreadAccessRoleChoices.EDITOR,
    )
    sender_contact = factories.ContactFactory(mailbox=mailbox_sender)

    return factories.MessageFactory(
        thread=thread,
        sender=sender_contact,
        is_draft=True,
        subject="Test Message",
        signature=signature_template,
    )


@pytest.mark.django_db
class TestSendOutboundMessage:
    """Unit tests for the send_outbound_message function."""

    @pytest.fixture
    def draft_message(self):
        """Create a valid (not actually draft) message with sender and recipients."""
        sender_contact = factories.ContactFactory(email="sender@sendtest.com")
        mailbox = sender_contact.mailbox
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=False,
            is_sender=True,
            subject="Test Outbound",
        )
        # Create a blob with the raw MIME content
        blob = factories.BlobFactory(
            mailbox=mailbox,
            content=b"From: sender@sendtest.com\nTo: to@example.com\nSubject: Test Outbound\n\nTest body",
            content_type="message/rfc822",
        )
        message.blob = blob
        message.save()
        # Add recipients
        to_contact = factories.ContactFactory(mailbox=mailbox, email="to@example.com")
        cc_contact = factories.ContactFactory(mailbox=mailbox, email="cc@example.com")
        cc_contact2 = factories.ContactFactory(mailbox=mailbox, email="cc2@example.com")
        bcc_contact = factories.ContactFactory(
            mailbox=mailbox, email="bcc@example2.com"
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=to_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=cc_contact,
            type=models.MessageRecipientTypeChoices.CC,
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=cc_contact2,
            type=models.MessageRecipientTypeChoices.CC,
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=bcc_contact,
            type=models.MessageRecipientTypeChoices.BCC,
        )
        return message

    @patch("core.mda.outbound.send_smtp_mail")  # Mock SMTP client
    @override_settings(
        MTA_OUT_MODE="relay",
        MTA_OUT_RELAY_HOST="smtp.test:1025",
        # Ensure other auth settings are None for this test
        MTA_OUT_RELAY_USERNAME="smtp_user",
        MTA_OUT_RELAY_PASSWORD="smtp_pass",
        OPENSEARCH_INDEX_THREADS=False,
    )
    def test_outbound_send_relay(self, mock_smtp_send, draft_message):
        """Test sending via SMTP relay."""

        mock_smtp_send.return_value = {
            "to@example.com": {
                "delivered": True,
                "error": None,
            },
            "cc@example.com": {
                "delivered": False,
                "error": "Temp refused",
                "retry": True,
            },
            "cc2@example.com": {
                "delivered": False,
                "error": "Not good this one",
            },
            "bcc@example2.com": {
                "delivered": True,
                "error": None,
            },
        }

        outbound.send_message(draft_message)

        # Check SMTP calls
        mock_smtp_send.assert_called_once_with(
            smtp_host="smtp.test",
            smtp_port=1025,
            envelope_from=draft_message.sender.email,
            recipient_emails={
                "to@example.com",
                "cc@example.com",
                "cc2@example.com",
                "bcc@example2.com",
            },
            message_content=draft_message.blob.get_content(),
            smtp_username="smtp_user",
            smtp_password="smtp_pass",
            smtp_tls_security_level="may",
        )

        # Check message object updated
        draft_message.refresh_from_db()
        assert not draft_message.is_draft
        assert draft_message.sent_at is not None

        assert draft_message.recipients.count() == 4
        assert (
            draft_message.recipients.filter(
                delivery_status=enums.MessageDeliveryStatusChoices.SENT_EXTERNAL
            ).count()
            == 2
        )
        assert (
            draft_message.recipients.filter(
                contact__email="cc@example.com",
                delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
            ).count()
            == 1
        )
        assert (
            draft_message.recipients.filter(
                contact__email="cc2@example.com",
                delivery_status=enums.MessageDeliveryStatusChoices.FAILED,
            ).count()
            == 1
        )

    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    @patch("core.mda.outbound_direct.send_smtp_mail")
    @override_settings(
        MTA_OUT_MODE="direct",
        MTA_OUT_DIRECT_PROXIES=["socks5://proxyuser:proxyuser@smtp.proxy:1080"],
        OPENSEARCH_INDEX_THREADS=False,
    )
    def test_outbound_send_direct(self, mock_smtp_send, mock_resolve, draft_message):
        """Test sending via direct connection with MX fallback logic."""

        def smtp_return_value(*args, **kwargs):
            if kwargs["recipient_emails"] == {
                "to@example.com",
                "cc@example.com",
                "cc2@example.com",
            }:
                return {
                    "to@example.com": {
                        "delivered": False,
                        "error": "Temp refused",
                        "retry": True,
                    },
                    "cc@example.com": {
                        "delivered": False,
                        "error": "Temp refused",
                        "retry": True,
                    },
                    "cc2@example.com": {
                        "delivered": False,
                        "error": "Not good this one",
                    },
                }
            if kwargs["recipient_emails"] == {"bcc@example2.com"}:
                return {
                    "bcc@example2.com": {"delivered": True},
                }
            if kwargs["recipient_emails"] == {"cc@example.com", "to@example.com"}:
                # This is the retry attempt on the second MX
                return {
                    "cc@example.com": {
                        "delivered": True,  # Success on retry
                        "error": None,
                    },
                    "to@example.com": {
                        "delivered": False,
                        "error": "Temp refused",
                        "retry": True,
                    },
                }
            return {}

        mock_smtp_send.side_effect = smtp_return_value

        def resolve_return_value(domain, record_type, **kwargs):
            lookup_data = {
                ("example.com", "MX"): [
                    MagicMock(preference=10, exchange="mx1.example.com"),
                    MagicMock(preference=15, exchange="mx1-5.example.com"),
                    MagicMock(preference=20, exchange="mx2.example.com"),
                    MagicMock(preference=30, exchange="mx3.example.com"),
                ],
                ("example2.com", "MX"): [
                    MagicMock(preference=10, exchange="mx1.example2.com"),
                    MagicMock(preference=20, exchange="mx2.example2.com"),
                ],
                ("mx1.example.com", "A"): ["1.1.0.9"],
                ("mx2.example.com", "A"): ["1.2.0.9"],
                ("mx3.example.com", "A"): None,
                ("mx1-5.example.com", "A"): None,
                ("mx1.example2.com", "A"): ["2.1.0.9"],
                ("mx2.example2.com", "A"): ["2.2.0.9"],
            }
            return lookup_data.get((domain, record_type))

        mock_resolve.side_effect = resolve_return_value

        outbound.send_message(draft_message)

        # Check message object updated
        draft_message.refresh_from_db()
        assert not draft_message.is_draft
        assert draft_message.sent_at is not None

        assert draft_message.recipients.count() == 4
        assert (
            draft_message.recipients.filter(
                delivery_status=enums.MessageDeliveryStatusChoices.SENT_EXTERNAL
            ).count()
            == 2
        )
        assert (
            draft_message.recipients.filter(
                contact__email="to@example.com",
                delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
            ).count()
            == 1
        )
        assert (
            draft_message.recipients.filter(
                contact__email="cc2@example.com",
                delivery_status=enums.MessageDeliveryStatusChoices.FAILED,
            ).count()
            == 1
        )

        # Check SMTP calls
        # 1. bcc@example2.com to mx1.example2.com (success)
        # 2. (to@example.com success, cc@example.com retry, cc2@example.com failed) to mx1.example.com
        # 3. cc@example.com to mx2.example.com (retry attempt)
        assert len(mock_smtp_send.mock_calls) == 3

        sorted_calls = sorted(mock_smtp_send.mock_calls, key=lambda x: x[2]["smtp_ip"])

        expected_proxy = SmtpProxy(
            host="smtp.proxy",
            port=1080,
            username="proxyuser",
            password="proxyuser",
            sender_hostname="smtp.proxy",
        )

        # Check first call - to@example.com, cc@example.com, cc2@example.com to mx1.example.com
        assert sorted_calls[0] == call(
            smtp_host="mx1.example.com",
            smtp_ip="1.1.0.9",
            smtp_port=25,
            envelope_from=draft_message.sender.email,
            recipient_emails={"to@example.com", "cc@example.com", "cc2@example.com"},
            message_content=draft_message.blob.get_content(),
            smtp_tls_security_level="may",
            proxy=expected_proxy,
        )

        # Check second call - cc@example.com, to@example.com retry to mx2.example.com
        assert sorted_calls[1] == call(
            smtp_host="mx2.example.com",
            smtp_ip="1.2.0.9",
            smtp_port=25,
            envelope_from=draft_message.sender.email,
            recipient_emails={"cc@example.com", "to@example.com"},
            message_content=draft_message.blob.get_content(),
            smtp_tls_security_level="may",
            proxy=expected_proxy,
        )

        # Check third call - bcc@example2.com to mx1.example2.com
        assert sorted_calls[2] == call(
            smtp_host="mx1.example2.com",
            smtp_ip="2.1.0.9",
            smtp_port=25,
            envelope_from=draft_message.sender.email,
            recipient_emails={"bcc@example2.com"},
            message_content=draft_message.blob.get_content(),
            smtp_tls_security_level="may",
            proxy=expected_proxy,
        )

    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    @patch("core.mda.outbound_direct.send_smtp_mail")
    @override_settings(
        MTA_OUT_MODE="direct",
        OPENSEARCH_INDEX_THREADS=False,
    )
    def test_outbound_send_direct_no_mx(
        self, mock_smtp_send, mock_resolve, draft_message
    ):
        """Test sending via direct connection with no MX records."""

        def resolve_return_value(domain, record_type, **kwargs):
            # Without MX records, we should retry on the A record
            if domain == "example2.com" and record_type == "MX":
                raise dns.resolver.NoAnswer()
            return {("example.com", "MX"): [], ("example2.com", "A"): ["1.2.0.8"]}[
                (domain, record_type)
            ]

        mock_resolve.side_effect = resolve_return_value

        def smtp_return_value(*args, **kwargs):
            if kwargs["recipient_emails"] == {"bcc@example2.com"}:
                return {
                    "bcc@example2.com": {"delivered": True},
                }
            raise ValueError("Should not be called")

        mock_smtp_send.side_effect = smtp_return_value

        outbound.send_message(draft_message)

        mock_smtp_send.assert_called_once_with(
            smtp_host="example2.com",
            smtp_ip="1.2.0.8",
            smtp_port=25,
            envelope_from=draft_message.sender.email,
            recipient_emails={"bcc@example2.com"},
            message_content=draft_message.blob.get_content(),
            smtp_tls_security_level="may",
            proxy=None,
        )

        # Check message object updated
        draft_message.refresh_from_db()
        assert not draft_message.is_draft
        assert draft_message.sent_at is not None

        assert (
            draft_message.recipients.filter(
                delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
            ).count()
            == 3
        )
        assert (
            draft_message.recipients.filter(
                contact__email="bcc@example2.com",
                delivery_status=enums.MessageDeliveryStatusChoices.SENT_EXTERNAL,
            ).count()
            == 1
        )

    def test_outbound_send_refuses_spam_flagged_message(self, draft_message):
        """``send_message`` must refuse to emit a spam-flagged record."""
        draft_message.is_spam = True
        draft_message.save(update_fields=["is_spam"])

        with pytest.raises(ValueError, match="flagged as spam"):
            outbound.send_message(draft_message)


class TestSendMessageRedisLock(TransactionTestCase):
    """Unit tests for the Redis lock functionality in send_message function."""

    def setUp(self):
        """Set up test data."""
        super().setUp()
        self.sender_contact = factories.ContactFactory(email="sender@sendtest.com")
        self.mailbox = self.sender_contact.mailbox
        self.thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=self.mailbox,
            thread=self.thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        self.message = factories.MessageFactory(
            thread=self.thread,
            sender=self.sender_contact,
            is_draft=False,
            is_sender=True,
            subject="Test Lock",
        )
        # Create a blob with the raw MIME content
        blob = factories.BlobFactory(
            mailbox=self.mailbox,
            content=b"From: sender@sendtest.com\nTo: to@example.com\nSubject: Test Lock\n\nTest body",
            content_type="message/rfc822",
        )
        self.message.blob = blob
        self.message.save()
        # Add recipients
        to_contact = factories.ContactFactory(
            mailbox=self.mailbox, email="to@example.external"
        )
        factories.MessageRecipientFactory(
            message=self.message,
            contact=to_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )

    def test_redis_lock_prevents_double_send_concurrent(self):
        """Test that Redis lock prevents multiple workers from sending the same message concurrently."""
        # Clear any existing locks
        cache.clear()

        # Track which workers actually called the outbound function
        outbound_calls = []
        call_lock = threading.Lock()

        def send_message_worker(worker_id, message):
            """Worker function that tries to send the message."""
            nonlocal outbound_calls

            with patch("core.mda.outbound.send_outbound_message") as mock_send_outbound:

                def mock_send_with_delay(*args, **kwargs):
                    time.sleep(1)  # Simulate actual email sending time
                    with call_lock:
                        outbound_calls.append(worker_id)
                    return {"to@example.external": {"delivered": True, "error": None}}

                mock_send_outbound.side_effect = mock_send_with_delay

                outbound.send_message(message, force_mta_out=True)

        # Start two concurrent workers
        thread1 = threading.Thread(target=send_message_worker, args=(1, self.message))
        thread1.start()

        time.sleep(0.5)

        thread2 = threading.Thread(target=send_message_worker, args=(2, self.message))
        thread2.start()

        # Wait for both threads to complete
        thread1.join()
        thread2.join()

        # Verify only one worker actually called the outbound function
        assert len(outbound_calls) == 1, (
            f"Expected 1 outbound call, got {len(outbound_calls)}: {outbound_calls}"
        )

        # Verify the message was processed
        self.message.refresh_from_db()
        assert self.message.sent_at is not None

    def test_redis_lock_released_after_success(self):
        """Test that Redis lock is released after successful message sending."""
        # Clear any existing locks
        cache.clear()

        # Mock the outbound delivery
        with patch("core.mda.outbound.send_outbound_message") as mock_send_outbound:
            mock_send_outbound.return_value = {
                "to@example.com": {"delivered": True, "error": None}
            }

            # Send the message
            outbound.send_message(self.message, force_mta_out=True)

            # Verify the lock was released by checking we can acquire it again
            lock_key = f"send_message_lock:{self.message.id}"
            assert cache.add(
                lock_key, "test", 60
            )  # Should succeed if lock was released

            # Clean up
            cache.delete(lock_key)

    def test_redis_lock_released_after_exception(self):
        """Test that Redis lock is released even when an exception occurs."""
        # Clear any existing locks
        cache.clear()

        # Mock send_outbound_message to raise an exception
        with patch("core.mda.outbound.send_outbound_message") as mock_send_outbound:
            mock_send_outbound.side_effect = Exception("Test exception")

            # Send the message (exception was raised and caught)
            outbound.send_message(self.message, force_mta_out=True)

            # Verify the lock was still released
            lock_key = f"send_message_lock:{self.message.id}"
            assert cache.add(
                lock_key, "test", 60
            )  # Should succeed if lock was released

            # Clean up
            cache.delete(lock_key)

    def test_redis_lock_timeout_prevents_deadlock(self):
        """Test that Redis lock has a timeout to prevent deadlocks."""
        # Clear any existing locks
        cache.clear()

        # Manually set a lock to simulate a stuck worker
        lock_key = f"send_message_lock:{self.message.id}"
        cache.set(lock_key, "stuck_worker", 1)  # 1 second timeout

        # Wait for the lock to expire
        time.sleep(1.1)

        # Now the message should be processable again
        with patch("core.mda.outbound.send_outbound_message") as mock_send_outbound:
            mock_send_outbound.return_value = {
                "to@example.com": {"delivered": True, "error": None}
            }

            outbound.send_message(self.message, force_mta_out=True)

            # Verify the message was processed
            self.message.refresh_from_db()
            assert self.message.sent_at is not None


@pytest.mark.django_db
class TestPrepareOutboundMessageSignature:
    """Test signature handling in prepare_outbound_message function."""

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_html_signature(
        self, user, signature_template, mailbox_sender, mailbox_access
    ):
        """Test preparing message with HTML signature."""
        html_body = "<p>Hello world!</p>"
        text_body = "Hello world!"
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=signature_template,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "Hello world!" in content
        # Drop quoted-printable soft line breaks before searching, since stdlib
        # may fold long HTML lines with =\r\n.
        unfolded = content.replace("=\r\n", "")
        assert (
            "Best regards,<br>John Doe<br>Software Engineer<br>Engineering</p>"
            in unfolded
        )

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_text_signature(
        self, user, signature_template, mailbox_sender, mailbox_access
    ):
        """Test preparing message with text signature."""
        html_body = None
        text_body = "Hello world!"
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=signature_template,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "Hello world!" in content
        assert (
            "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering" in content
        )

    def test_prepare_outbound_message_without_signature(
        self, user, mailbox_sender, mailbox_access
    ):
        """Test preparing message without signature."""
        html_body = "<p>Hello world!</p>"
        text_body = "Hello world!"
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=None,
        )

        result = outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )

        assert result is True
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "Hello world!" in content
        assert "Best regards" not in content

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_reply_and_signature(
        self, user, signature_template, mailbox_sender, mailbox_access
    ):
        """Test preparing message with both reply content and signature."""
        # Create a parent message for reply
        parent_message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            subject="Original Subject",
        )
        message = factories.MessageFactory(
            thread=parent_message.thread,
            sender=parent_message.sender,
            subject="Re: Original Subject",
            parent=parent_message,
            signature=signature_template,
        )

        html_body = "<p>This is a reply</p>"
        text_body = "This is a reply"

        outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "This is a reply" in content
        assert (
            "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering" in content
        )

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_inactive_signature(
        self, user, mailbox_sender, mailbox_access
    ):
        """Test preparing message with inactive signature."""
        inactive_signature = factories.MessageTemplateFactory(
            name="Inactive Signature",
            html_body="<p>Inactive content</p>",
            text_body="Inactive content",
            type=enums.MessageTemplateTypeChoices.SIGNATURE,
            mailbox=mailbox_sender,
            is_active=False,
        )

        html_body = "<p>Hello world!</p>"
        text_body = "Hello world!"
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=inactive_signature,
        )

        result = outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )

        assert result is True
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "Hello world!" in content
        assert "Inactive content" not in content

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_unauthorized_signature(
        self, user, mailbox_sender, mailbox_access
    ):
        """Test preparing message with unauthorized signature."""
        other_mailbox = factories.MailboxFactory()
        unauthorized_signature = factories.MessageTemplateFactory(
            name="Unauthorized Signature",
            html_body="<p>Unauthorized content</p>",
            text_body="Unauthorized content",
            type=enums.MessageTemplateTypeChoices.SIGNATURE,
            mailbox=other_mailbox,
            is_active=True,
        )

        html_body = "<p>Hello world!</p>"
        text_body = "Hello world!"
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=unauthorized_signature,
        )

        result = outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )

        assert result is True
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "Hello world!" in content
        assert "Unauthorized content" not in content

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_prepare_outbound_message_with_only_signature(
        self, user, signature_template, mailbox_sender, mailbox_access
    ):
        """Test preparing message with only signature."""
        html_body = None
        text_body = None
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=signature_template,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert (
            "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering" in content
        )
        # Drop quoted-printable soft line breaks before searching, since stdlib
        # may fold long HTML lines with =\r\n.
        unfolded = content.replace("=\r\n", "")
        assert (
            "Best regards,<br>John Doe<br>Software Engineer<br>Engineering</p>"
            in unfolded
        )

        # Same with empty text and html bodies
        html_body = ""
        text_body = ""
        outbound.prepare_outbound_message(
            mailbox_sender, message, text_body, html_body, user
        )
        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert (
            "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering" in content
        )
        # Drop quoted-printable soft line breaks before searching, since stdlib
        # may fold long HTML lines with =\r\n.
        unfolded = content.replace("=\r\n", "")
        assert (
            "Best regards,<br>John Doe<br>Software Engineer<br>Engineering</p>"
            in unfolded
        )


@pytest.mark.django_db
class TestPrepareOutboundMessageSenderUser:
    """Test that prepare_outbound_message stores the sender_user on the message."""

    def test_prepare_outbound_message_sets_sender_user(
        self, user, mailbox_sender, mailbox_access
    ):
        """prepare_outbound_message should assign the user as sender_user."""
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test sender_user",
        )
        assert message.sender_user is None

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>", user
        )
        message.refresh_from_db()
        assert message.sender_user == user


@pytest.mark.django_db
class TestPrepareOutboundMessageReadAt:
    """Test that prepare_outbound_message marks the thread as read for the sender."""

    def test_prepare_outbound_message_updates_sender_read_at(self, mailbox_sender):
        """Sending a message should update the sender's ThreadAccess.read_at
        so the thread does not appear unread in their own mailbox."""
        thread = factories.ThreadFactory()
        access = factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=True,
            subject="Test read_at",
        )
        assert access.read_at is None

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>"
        )

        access.refresh_from_db()
        message.refresh_from_db()
        assert access.read_at is not None
        assert access.read_at >= message.created_at


@pytest.mark.django_db
class TestUndisclosedRecipientsHeader:
    """A message with no To recipient (e.g. Bcc-only) must get an empty-group
    ``To: undisclosed-recipients:;`` header (RFC 4356) so receivers don't
    flag the missing To as a spam signal. Messages that already have a
    visible To must be left untouched."""

    def _make_draft(self, mailbox_sender):
        return factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=None,
        )

    def _add_recipient(self, message, mailbox_sender, email, kind):
        factories.MessageRecipientFactory(
            message=message,
            contact=factories.ContactFactory(mailbox=mailbox_sender, email=email),
            type=kind,
        )

    def test_bcc_only_message_gets_undisclosed_to_header(
        self, user, mailbox_sender, mailbox_access
    ):
        """Bcc-only send → placeholder added, Bcc address never leaked."""
        message = self._make_draft(mailbox_sender)
        self._add_recipient(
            message,
            mailbox_sender,
            "bcc@example.com",
            models.MessageRecipientTypeChoices.BCC,
        )

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "Hello", "<p>Hello</p>", user
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "To: undisclosed-recipients:;" in content
        # The Bcc recipient must never end up in a visible header.
        assert "bcc@example.com" not in content

    def test_to_recipient_suppresses_placeholder(
        self, user, mailbox_sender, mailbox_access
    ):
        """A visible To recipient → no placeholder, real To preserved."""
        message = self._make_draft(mailbox_sender)
        self._add_recipient(
            message,
            mailbox_sender,
            "to@example.com",
            models.MessageRecipientTypeChoices.TO,
        )
        self._add_recipient(
            message,
            mailbox_sender,
            "bcc@example.com",
            models.MessageRecipientTypeChoices.BCC,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>", user
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "undisclosed-recipients" not in content
        assert "to@example.com" in content

    def test_cc_only_message_gets_undisclosed_to_header(
        self, user, mailbox_sender, mailbox_access
    ):
        """A Cc-only send still has no To, so it gets the placeholder while
        keeping the visible Cc recipient."""
        message = self._make_draft(mailbox_sender)
        self._add_recipient(
            message,
            mailbox_sender,
            "cc@example.com",
            models.MessageRecipientTypeChoices.CC,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>", user
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "To: undisclosed-recipients:;" in content
        assert "cc@example.com" in content

    def test_raw_mime_submission_without_to_gets_placeholder(
        self, user, mailbox_sender, mailbox_access
    ):
        """The raw-MIME submission path (e.g. a Bcc-only send whose MIME has no
        To header) is normalized too, before the blob is signed."""
        message = self._make_draft(mailbox_sender)
        self._add_recipient(
            message,
            mailbox_sender,
            "bcc@example.com",
            models.MessageRecipientTypeChoices.BCC,
        )
        raw_mime = (
            b"From: sender@example.com\r\n"
            b"Subject: Raw MIME test\r\n"
            b"Date: Mon, 16 Jun 2026 12:00:00 +0000\r\n"
            b"\r\n"
            b"Body without a To header.\r\n"
        )

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "", "", user, raw_mime=raw_mime
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "To: undisclosed-recipients:;" in content
        # The Bcc recipient must never end up in a visible header.
        assert "bcc@example.com" not in content

    def test_undisclosed_to_header_is_covered_by_dkim(
        self, user, mailbox_sender, mailbox_access
    ):
        """The placeholder is added before signing, so DKIM (which signs To)
        covers it — the key improvement over patching the blob afterwards."""
        dkim_key = models.DKIMKey.objects.create(
            selector="testselector",
            private_key=_TEST_DKIM_PRIVATE_KEY,
            public_key=_TEST_DKIM_PUBLIC_KEY,
            key_size=1024,
            is_active=True,
            domain=mailbox_sender.domain,
        )
        message = self._make_draft(mailbox_sender)
        self._add_recipient(
            message,
            mailbox_sender,
            "bcc@example.com",
            models.MessageRecipientTypeChoices.BCC,
        )

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>", user
        )

        message.refresh_from_db()
        email_source = message.blob.get_content()
        assert b"To: undisclosed-recipients:;" in email_source
        assert b"DKIM-Signature:" in email_source

        domain = mailbox_sender.domain.name

        def get_dns_txt(fqdn, **kwargs):
            if fqdn == f"testselector._domainkey.{domain}.".encode():
                return f"v=DKIM1; k=rsa; p={dkim_key.public_key}".encode()
            return None

        assert dkim_verify(email_source, dnsfunc=get_dns_txt), (
            "DKIM verification failed"
        )

        # Tampering with the now-signed To header must break the signature,
        # proving the placeholder is part of the signed header set.
        tampered = email_source.replace(
            b"To: undisclosed-recipients:;", b"To: attacker@evil.com"
        )
        assert not dkim_verify(tampered, dnsfunc=get_dns_txt)


@pytest.mark.django_db
class TestXMailerHeader:
    """Outbound mail advertises the sending application through the
    ``X-Mailer`` header — a small positive deliverability signal whose
    absence reads as bulk/templated mail."""

    def _make_draft(self, mailbox_sender):
        message = factories.MessageFactory(
            thread=factories.ThreadFactory(),
            sender=factories.ContactFactory(mailbox=mailbox_sender),
            is_draft=True,
            subject="Test Message",
            signature=None,
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=factories.ContactFactory(
                mailbox=mailbox_sender, email="to@example.com"
            ),
            type=models.MessageRecipientTypeChoices.TO,
        )
        return message

    @override_settings(MDA_HEADER_XMAILER="Messages Test")
    @override_settings(RELEASE="v6.0.0")
    def test_header_emitted_with_release(self, user, mailbox_sender, mailbox_access):
        """X-Mailer carries the product name and the running release."""
        message = self._make_draft(mailbox_sender)

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "Hello", "<p>Hello</p>", user
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "X-Mailer: Messages Test v6.0.0\r\n" in content

    @override_settings(RELEASE="NA")
    def test_header_emitted_without_release(self, user, mailbox_sender, mailbox_access):
        """With no usable release, X-Mailer carries just the product name."""
        message = self._make_draft(mailbox_sender)

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "Hello", "<p>Hello</p>", user
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert f"X-Mailer: {settings.MDA_HEADER_XMAILER}\r\n" in content

    @override_settings(RELEASE="6.0.0")
    def test_header_emitted_on_raw_mime_path(
        self, user, mailbox_sender, mailbox_access
    ):
        """Raw-MIME submissions get the same X-Mailer signal as composed mail."""
        message = self._make_draft(mailbox_sender)
        raw_mime = (
            b"From: sender@example.com\r\n"
            b"To: to@example.com\r\n"
            b"Subject: Raw MIME test\r\n"
            b"\r\n"
            b"Body.\r\n"
        )

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "", "", user, raw_mime=raw_mime
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert f"X-Mailer: {settings.MDA_HEADER_XMAILER} {settings.RELEASE}" in content

    def test_caller_supplied_xmailer_is_preserved_on_raw_mime_path(
        self, user, mailbox_sender, mailbox_access
    ):
        """A caller-set X-Mailer is kept as-is, not duplicated nor overwritten."""
        message = self._make_draft(mailbox_sender)
        raw_mime = (
            b"From: sender@example.com\r\n"
            b"To: to@example.com\r\n"
            b"X-Mailer: CustomClient 1.0\r\n"
            b"Subject: Raw MIME test\r\n"
            b"\r\n"
            b"Body.\r\n"
        )

        assert (
            outbound.prepare_outbound_message(
                mailbox_sender, message, "", "", user, raw_mime=raw_mime
            )
            is True
        )

        message.refresh_from_db()
        content = message.blob.get_content().decode()
        assert "X-Mailer: CustomClient 1.0" in content
        assert content.count("X-Mailer:") == 1


@pytest.mark.django_db
class TestSendMessageDKIMVerification:
    """Test DKIM verification in send_message."""

    @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True)
    @patch("core.mda.signing.dns.resolver.resolve")
    @patch("core.mda.outbound.send_outbound_message")
    def test_dkim_verification_success(
        self, mock_send_outbound, mock_dns_resolve, mailbox_sender
    ):
        """Test that DKIM verification succeeds and message is sent."""

        # Create a message with external recipient
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=False,
            is_sender=True,
            subject="Test DKIM",
        )

        private_key, public_key = generate_dkim_key(key_size=1024)
        dkim_key = models.DKIMKey.objects.create(
            selector="testselector",
            private_key=private_key,
            public_key=public_key,
            key_size=1024,
            is_active=True,
            domain=mailbox_sender.domain,
        )

        # Prepare and sign the message
        raw_mime = f"From: {sender_contact.email}\r\nTo: external@other.com\r\nSubject: Test\r\n\r\nBody\r\n".encode()
        signature_header = sign_message_dkim(raw_mime, mailbox_sender.domain)
        signed_mime = signature_header + b"\r\n" + raw_mime

        # Create blob with signed message
        blob = factories.BlobFactory(
            mailbox=mailbox_sender, content=signed_mime, content_type="message/rfc822"
        )
        message.blob = blob
        message.save()

        # Add external recipient
        external_contact = factories.ContactFactory(
            mailbox=mailbox_sender, email="external@other.com"
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=external_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )

        # Mock DNS to return the DKIM public key
        def mock_dns_resolve_func(query_name, record_type, **kwargs):
            expected_fqdn = f"testselector._domainkey.{mailbox_sender.domain.name}"
            if record_type == "TXT" and query_name == expected_fqdn:
                mock_answer = MagicMock()
                mock_answer.strings = [
                    f"v=DKIM1; k=rsa; p={dkim_key.public_key}".encode()
                ]
                return [mock_answer]
            raise dns.resolver.NoAnswer()

        mock_dns_resolve.side_effect = mock_dns_resolve_func

        # Mock successful send
        mock_send_outbound.return_value = {"external@other.com": {"delivered": True}}

        # Send the message
        outbound.send_message(message)

        # Verify DNS was queried for DKIM record
        assert mock_dns_resolve.called

        # Verify message was sent (not marked for retry)
        message.refresh_from_db()
        recipient = message.recipients.first()
        assert (
            recipient.delivery_status
            == enums.MessageDeliveryStatusChoices.SENT_EXTERNAL
        )
        assert mock_send_outbound.called

    @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True)
    @patch("core.mda.signing.dns.resolver.resolve")
    @patch("core.mda.outbound.send_outbound_message")
    def test_dkim_verification_failure_marks_for_retry(
        self, mock_send_outbound, mock_dns_resolve, mailbox_sender
    ):
        """Test that DKIM verification failure marks recipients for retry."""
        # Create a message with external recipient
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=False,
            is_sender=True,
            subject="Test DKIM",
        )

        private_key, public_key = generate_dkim_key(key_size=1024)
        _dkim_key = models.DKIMKey.objects.create(
            selector="testselector",
            private_key=private_key,
            public_key=public_key,
            key_size=1024,
            is_active=True,
            domain=mailbox_sender.domain,
        )

        # Prepare and sign the message
        raw_mime = f"From: {sender_contact.email}\r\nTo: external@other.com\r\nSubject: Test\r\n\r\nBody\r\n".encode()
        signature_header = sign_message_dkim(raw_mime, mailbox_sender.domain)
        signed_mime = signature_header + b"\r\n" + raw_mime

        # Create blob with signed message
        blob = factories.BlobFactory(
            mailbox=mailbox_sender, content=signed_mime, content_type="message/rfc822"
        )
        message.blob = blob
        message.save()

        # Add external recipient
        external_contact = factories.ContactFactory(
            mailbox=mailbox_sender, email="external@other.com"
        )
        recipient = factories.MessageRecipientFactory(
            message=message,
            contact=external_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )

        # Mock DNS to fail (no DKIM record found)
        mock_dns_resolve.side_effect = dns.resolver.NoAnswer()

        # Send the message
        outbound.send_message(message)

        # Verify DNS was queried
        assert mock_dns_resolve.called

        # Verify message was NOT sent
        assert not mock_send_outbound.called

        # Verify recipient was marked for retry
        recipient.refresh_from_db()
        assert recipient.delivery_status == enums.MessageDeliveryStatusChoices.RETRY
        assert recipient.retry_at is not None
        assert "DKIM verification failed" in recipient.delivery_message

    @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True)
    @patch("core.mda.signing.dns.resolver.resolve")
    @patch("core.mda.outbound.deliver_inbound_message")
    def test_dkim_verification_skipped_for_internal_recipients(
        self, mock_deliver_inbound, mock_dns_resolve, mailbox_sender
    ):
        """Test that DKIM verification is skipped for internal recipients."""
        # Create a message with internal recipient
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=False,
            is_sender=True,
            subject="Test DKIM",
        )

        # Create blob with message
        raw_mime = (
            f"From: {sender_contact.email}\r\n"
            + f"To: internal@{mailbox_sender.domain.name}\r\n"
            + "Subject: Test\r\n\r\nBody\r\n"
        ).encode()
        blob = factories.BlobFactory(
            mailbox=mailbox_sender, content=raw_mime, content_type="message/rfc822"
        )
        message.blob = blob
        message.save()

        # Add internal recipient (same domain)
        # Create mailbox with matching local_part
        internal_mailbox = factories.MailboxFactory(
            domain=mailbox_sender.domain, local_part="internal"
        )
        internal_contact = factories.ContactFactory(
            mailbox=internal_mailbox, email=f"internal@{mailbox_sender.domain.name}"
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=internal_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )

        # Mock internal delivery
        mock_deliver_inbound.return_value = True

        # Send the message
        outbound.send_message(message)

        # Verify DNS was NOT queried (DKIM verification skipped for internal)
        assert not mock_dns_resolve.called

        # Verify internal delivery was attempted
        assert mock_deliver_inbound.called

    @override_settings(MESSAGES_ALLOW_INTERNAL_DELIVERY=False)
    @patch("core.mda.outbound.send_outbound_message")
    @patch("core.mda.outbound.deliver_inbound_message")
    def test_internal_delivery_disabled_routes_local_recipient_external(
        self, mock_deliver_inbound, mock_send_outbound, mailbox_sender
    ):
        """With MESSAGES_ALLOW_INTERNAL_DELIVERY=False, a same-instance
        recipient is routed out through the MTA instead of the internal
        fast path."""
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=False,
            is_sender=True,
            subject="No internal fast path",
        )
        recipient_email = f"internal@{mailbox_sender.domain.name}"
        raw_mime = (
            f"From: {sender_contact.email}\r\n"
            + f"To: {recipient_email}\r\n"
            + "Subject: Test\r\n\r\nBody\r\n"
        ).encode()
        message.blob = factories.BlobFactory(
            mailbox=mailbox_sender, content=raw_mime, content_type="message/rfc822"
        )
        message.save()
        # A genuinely local recipient (same instance).
        internal_mailbox = factories.MailboxFactory(
            domain=mailbox_sender.domain, local_part="internal"
        )
        internal_contact = factories.ContactFactory(
            mailbox=internal_mailbox, email=recipient_email
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=internal_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )
        mock_send_outbound.return_value = {recipient_email: {"delivered": True}}

        outbound.send_message(message)

        # The local recipient went out through the MTA, not the internal
        # fast path.
        assert not mock_deliver_inbound.called
        assert mock_send_outbound.called


def _create_spf_test_message(mailbox_sender):
    """Create the common objects needed by SPF check tests.

    Returns (message, sender_contact, external_contact, recipient, thread).
    """
    thread = factories.ThreadFactory()
    factories.ThreadAccessFactory(
        mailbox=mailbox_sender,
        thread=thread,
        role=enums.ThreadAccessRoleChoices.EDITOR,
    )
    sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
    message = factories.MessageFactory(
        thread=thread,
        sender=sender_contact,
        is_draft=False,
        is_sender=True,
        subject="Test SPF",
    )

    raw_mime = (
        f"From: {sender_contact.email}\r\n"
        "To: external@other.com\r\n"
        "Subject: Test\r\n\r\nBody\r\n"
    ).encode()
    blob = factories.BlobFactory(
        mailbox=mailbox_sender, content=raw_mime, content_type="message/rfc822"
    )
    message.blob = blob
    message.save()

    external_contact = factories.ContactFactory(
        mailbox=mailbox_sender, email="external@other.com"
    )
    recipient = factories.MessageRecipientFactory(
        message=message,
        contact=external_contact,
        type=models.MessageRecipientTypeChoices.TO,
    )

    return message, sender_contact, external_contact, recipient, thread


@pytest.mark.django_db
class TestSendMessageSPFCheck:
    """Test SPF check in send_message."""

    def setup_method(self):
        """Clear SPF cache so each test hits the mocked DNS resolver."""
        cache.clear()

    @override_settings(MESSAGES_SPF_CHECK_OUTGOING=True)
    @patch("core.services.dns.check.dns.resolver.resolve")
    @patch("core.mda.outbound.send_outbound_message")
    def test_spf_check_failure_marks_for_retry(
        self, mock_send_outbound, mock_dns_resolve, mailbox_sender
    ):
        """When SPF check fails, external recipients are marked for retry."""
        message, _, _, recipient, _ = _create_spf_test_message(mailbox_sender)

        # DNS returns no SPF record → check_spf_status returns False
        mock_dns_resolve.side_effect = dns.resolver.NXDOMAIN()

        outbound.send_message(message)

        assert not mock_send_outbound.called
        recipient.refresh_from_db()
        assert recipient.delivery_status == enums.MessageDeliveryStatusChoices.RETRY
        assert "SPF check failed" in recipient.delivery_message

    @override_settings(
        MESSAGES_SPF_CHECK_OUTGOING=True,
        MESSAGES_DNS_RECORDS='[{"target":"","type":"txt",'
        '"value":"v=spf1 ip4:1.2.3.4 -all"}]',
    )
    @patch("core.services.dns.check.dns.resolver.resolve")
    @patch("core.mda.outbound.send_outbound_message")
    def test_spf_check_success_sends_message(
        self, mock_send_outbound, mock_dns_resolve, mailbox_sender
    ):
        """When SPF check passes, message is sent normally."""
        message, _, _, _, _ = _create_spf_test_message(mailbox_sender)

        def resolve_side_effect(name, record_type):
            if name == mailbox_sender.domain.name:
                rr = MagicMock()
                rr.strings = (b"v=spf1 ip4:1.2.3.4 -all",)
                answer = MagicMock()
                answer.rrset = [rr]
                return answer
            raise dns.resolver.NXDOMAIN()

        mock_dns_resolve.side_effect = resolve_side_effect
        mock_send_outbound.return_value = {"external@other.com": {"delivered": True}}

        outbound.send_message(message)

        assert mock_send_outbound.called

    @patch("core.mda.outbound.send_outbound_message")
    def test_spf_check_skipped_when_disabled(self, mock_send_outbound, mailbox_sender):
        """When MESSAGES_SPF_CHECK_OUTGOING is False (default), no SPF check."""
        message, _, _, _, _ = _create_spf_test_message(mailbox_sender)

        mock_send_outbound.return_value = {"external@other.com": {"delivered": True}}

        # No DNS mock needed — SPF check should not run
        with patch("core.services.dns.check.dns.resolver.resolve") as mock_resolve:
            outbound.send_message(message)
            # check_spf_status should not have been called
            assert not mock_resolve.called

        assert mock_send_outbound.called


# 1x1 red pixel PNG, small enough to be used in tests
TINY_PNG_B64 = (
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
    "2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
)


@pytest.mark.django_db
class TestPrepareOutboundMessageBase64Images:
    """Test base64 image extraction in prepare_outbound_message."""

    def _make_message(self, mailbox_sender, signature=None):
        """Helper to create a draft message with a recipient."""
        thread = factories.ThreadFactory()
        factories.ThreadAccessFactory(
            mailbox=mailbox_sender,
            thread=thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )
        sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
        message = factories.MessageFactory(
            thread=thread,
            sender=sender_contact,
            is_draft=True,
            subject="Test Base64 Images",
            signature=signature,
        )
        # Add a recipient so compose_email succeeds
        to_contact = factories.ContactFactory(
            mailbox=mailbox_sender, email="to@example.com"
        )
        factories.MessageRecipientFactory(
            message=message,
            contact=to_contact,
            type=models.MessageRecipientTypeChoices.TO,
        )
        return message

    def test_prepare_outbound_base64_in_signature_converted_to_inline_attachments(
        self, mailbox_sender, user, mailbox_access
    ):
        """Base64 images in the signature are extracted to inline CID attachments."""
        sig = factories.MessageTemplateFactory(
            name="Sig with image",
            html_body=f'<p>Regards</p><img src="data:image/png;base64,{TINY_PNG_B64}">',
            text_body="Regards",
            type=enums.MessageTemplateTypeChoices.SIGNATURE,
            is_active=True,
            mailbox=mailbox_sender,
        )
        message = self._make_message(mailbox_sender, signature=sig)

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>", user
        )

        message.refresh_from_db()
        raw = message.blob.get_content().decode(errors="replace")

        # The base64 data URI must no longer appear in the body
        assert "data:image/png;base64," not in raw
        # A CID reference must be present
        assert "cid:" in raw

    def test_prepare_outbound_base64_in_body_converted_to_inline_attachments(
        self, mailbox_sender
    ):
        """Base64 images in the HTML body itself are extracted to inline CID attachments."""
        message = self._make_message(mailbox_sender)
        html_body = f'<p>See image:</p><img src="data:image/png;base64,{TINY_PNG_B64}">'

        outbound.prepare_outbound_message(
            mailbox_sender, message, "See image", html_body
        )

        message.refresh_from_db()
        raw = message.blob.get_content().decode(errors="replace")

        assert "data:image/png;base64," not in raw
        assert "cid:" in raw

    def test_prepare_outbound_base64_has_attachments_set_when_present(
        self, mailbox_sender
    ):
        """has_attachments is True when base64 images are present even without blob attachments."""
        message = self._make_message(mailbox_sender)
        html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'

        outbound.prepare_outbound_message(mailbox_sender, message, "text", html_body)

        message.refresh_from_db()
        assert message.has_attachments is True

    def test_prepare_outbound_base64_has_attachments_false_when_none(
        self, mailbox_sender
    ):
        """has_attachments is False when there are no attachments nor base64 images."""
        message = self._make_message(mailbox_sender)

        outbound.prepare_outbound_message(
            mailbox_sender, message, "Hello", "<p>Hello</p>"
        )

        message.refresh_from_db()
        assert message.has_attachments is False

    @override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=10)
    def test_prepare_outbound_base64_count_toward_attachment_size_limit(
        self, mailbox_sender
    ):
        """A base64 image whose decoded size exceeds MAX_OUTGOING_ATTACHMENT_SIZE raises ValidationError."""
        message = self._make_message(mailbox_sender)
        # The tiny PNG decodes to ~69 bytes, well above the 10-byte limit
        html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'

        with pytest.raises(drf.exceptions.ValidationError) as exc_info:
            outbound.prepare_outbound_message(
                mailbox_sender, message, "text", html_body
            )

        assert "attachment size" in str(exc_info.value.detail).lower()

    @override_settings(MAX_OUTGOING_ATTACHMENT_SIZE=200)
    def test_prepare_outbound_base64_combined_blob_size_validation(
        self, mailbox_sender
    ):
        """Blob attachments + base64 images that together exceed the limit raise ValidationError."""
        message = self._make_message(mailbox_sender)

        # Create a blob attachment of 150 bytes (under the 200 byte limit alone),
        # owned by this draft via the per-message FK.
        factories.AttachmentFactory(
            mailbox=mailbox_sender,
            message=message,
            blob_size=150,
            name="file.bin",
        )

        # The tiny PNG (~69 bytes) + 150 bytes blob > 200 byte limit
        html_body = f'<img src="data:image/png;base64,{TINY_PNG_B64}">'

        with pytest.raises(drf.exceptions.ValidationError) as exc_info:
            outbound.prepare_outbound_message(
                mailbox_sender, message, "text", html_body
            )

        assert "attachment size" in str(exc_info.value.detail).lower()

    def test_prepare_outbound_base64_deduplicated_across_text_and_html(
        self, mailbox_sender
    ):
        """The same base64 image in both text and HTML bodies produces only one attachment."""
        message = self._make_message(mailbox_sender)

        img_data_uri = f"data:image/png;base64,{TINY_PNG_B64}"
        text_body = f"![img]({img_data_uri})"
        html_body = f'<img src="{img_data_uri}">'

        outbound.prepare_outbound_message(mailbox_sender, message, text_body, html_body)

        message.refresh_from_db()
        raw = message.blob.get_content().decode(errors="replace")

        # Both text and HTML bodies should reference the same CID.
        # Extract all cid references from the raw MIME.
        cid_refs = re.findall(r"cid:([a-zA-Z0-9@._-]+)", raw)
        # Deduplicate: all references should point to the same single CID
        unique_cids = set(cid_refs)
        assert len(unique_cids) == 1, (
            f"Expected exactly 1 unique CID (deduplicated), got {len(unique_cids)}: {unique_cids}"
        )
        # There should be at least 2 references (one in text part, one in HTML part)
        assert len(cid_refs) >= 2, (
            f"Expected at least 2 CID references (text + HTML), got {len(cid_refs)}"
        )


class TestOutboundDirectSSRF:
    """Resolved MX/A IPs are SSRF-validated before the SMTP worker dials them.

    A recipient domain (and therefore its MX records) is attacker-controlled,
    so a domain whose MX points at internal infrastructure must never make the
    direct-delivery worker connect there.
    """

    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    def test_resolve_hostname_ip_rejects_internal(self, mock_resolve):
        """An MX host whose only A record is internal yields no IP (skipped)."""
        mock_resolve.return_value = ["10.0.0.5"]
        assert resolve_hostname_ip("mx.evil.test") is None

    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    def test_resolve_hostname_ip_allows_public(self, mock_resolve):
        """A public A record is accepted and returned."""
        mock_resolve.return_value = ["93.184.216.34"]
        assert resolve_hostname_ip("mx.good.test") == "93.184.216.34"

    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    def test_resolve_hostname_ip_skips_internal_returns_public(self, mock_resolve):
        """Multi-A: the internal record is skipped, the public one is used."""
        mock_resolve.return_value = ["10.0.0.5", "93.184.216.34"]
        assert resolve_hostname_ip("mx.mixed.test") == "93.184.216.34"

    @patch("core.mda.outbound_direct.send_smtp_mail")
    @patch("core.mda.outbound_direct.dns.resolver.resolve")
    def test_send_via_mx_never_dials_internal_mx(self, mock_resolve, mock_smtp_send):
        """End to end: a domain whose MX → internal IP triggers no SMTP connect."""

        def resolve_side_effect(name, record_type, **kwargs):
            data = {
                ("evil.test", "MX"): [
                    MagicMock(preference=10, exchange="mx.evil.test"),
                ],
                ("mx.evil.test", "A"): ["10.0.0.5"],
            }
            return data.get((name, record_type))

        mock_resolve.side_effect = resolve_side_effect

        statuses = send_message_via_mx(
            "from@ours.test", ["victim@evil.test"], b"raw mime"
        )

        # The worker was never asked to open an SMTP connection.
        mock_smtp_send.assert_not_called()
        # And the recipient was not delivered.
        assert statuses["victim@evil.test"]["delivered"] is False
