|
1 | 1 | """Tests for the core.mda.outbound module.""" |
2 | | -# pylint: disable=unused-argument |
| 2 | +# pylint: disable=unused-argument,too-many-lines |
3 | 3 |
|
4 | 4 | import threading |
5 | 5 | import time |
|
13 | 13 |
|
14 | 14 | from core import enums, factories, models |
15 | 15 | from core.mda import outbound |
| 16 | +from core.mda.signing import generate_dkim_key, sign_message_dkim |
16 | 17 |
|
17 | 18 | SCHEMA_CUSTOM_ATTRIBUTES = { |
18 | 19 | "$schema": "https://json-schema.org/draft/2020-12/schema", |
@@ -827,3 +828,224 @@ def test_prepare_outbound_message_with_only_signature( |
827 | 828 | "Best regards,<br>John Doe<br>Software Engineer<br>Engineering</p>" |
828 | 829 | in content |
829 | 830 | ) |
| 831 | + |
| 832 | + |
| 833 | +@pytest.mark.django_db |
| 834 | +class TestSendMessageDKIMVerification: |
| 835 | + """Test DKIM verification in send_message.""" |
| 836 | + |
| 837 | + @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True) |
| 838 | + @patch("core.mda.signing.dns.resolver.resolve") |
| 839 | + @patch("core.mda.outbound.send_outbound_message") |
| 840 | + def test_dkim_verification_success( |
| 841 | + self, mock_send_outbound, mock_dns_resolve, mailbox_sender |
| 842 | + ): |
| 843 | + """Test that DKIM verification succeeds and message is sent.""" |
| 844 | + # Create a message with external recipient |
| 845 | + thread = factories.ThreadFactory() |
| 846 | + factories.ThreadAccessFactory( |
| 847 | + mailbox=mailbox_sender, |
| 848 | + thread=thread, |
| 849 | + role=enums.ThreadAccessRoleChoices.EDITOR, |
| 850 | + ) |
| 851 | + sender_contact = factories.ContactFactory(mailbox=mailbox_sender) |
| 852 | + message = factories.MessageFactory( |
| 853 | + thread=thread, |
| 854 | + sender=sender_contact, |
| 855 | + is_draft=False, |
| 856 | + is_sender=True, |
| 857 | + subject="Test DKIM", |
| 858 | + ) |
| 859 | + |
| 860 | + private_key, public_key = generate_dkim_key(key_size=1024) |
| 861 | + dkim_key = models.DKIMKey.objects.create( |
| 862 | + selector="testselector", |
| 863 | + private_key=private_key, |
| 864 | + public_key=public_key, |
| 865 | + key_size=1024, |
| 866 | + is_active=True, |
| 867 | + domain=mailbox_sender.domain, |
| 868 | + ) |
| 869 | + |
| 870 | + # Prepare and sign the message |
| 871 | + domain_name = mailbox_sender.domain.name |
| 872 | + raw_mime = f"From: test@{domain_name}\r\nTo: [email protected]\r\nSubject: Test\r\n\r\nBody\r\n". encode() |
| 873 | + signature_header = sign_message_dkim(raw_mime, mailbox_sender.domain) |
| 874 | + signed_mime = signature_header + b"\r\n" + raw_mime |
| 875 | + |
| 876 | + # Create blob with signed message |
| 877 | + blob = mailbox_sender.create_blob( |
| 878 | + content=signed_mime, content_type="message/rfc822" |
| 879 | + ) |
| 880 | + message.blob = blob |
| 881 | + message.save() |
| 882 | + |
| 883 | + # Add external recipient |
| 884 | + external_contact = factories.ContactFactory( |
| 885 | + mailbox=mailbox_sender, email="[email protected]" |
| 886 | + ) |
| 887 | + factories.MessageRecipientFactory( |
| 888 | + message=message, |
| 889 | + contact=external_contact, |
| 890 | + type=models.MessageRecipientTypeChoices.TO, |
| 891 | + ) |
| 892 | + |
| 893 | + # Mock DNS to return the DKIM public key |
| 894 | + def mock_dns_resolve_func(query_name, record_type, **kwargs): |
| 895 | + expected_fqdn = f"testselector._domainkey.{domain_name}" |
| 896 | + if record_type == "TXT" and query_name == expected_fqdn: |
| 897 | + mock_answer = MagicMock() |
| 898 | + mock_answer.strings = [ |
| 899 | + f"v=DKIM1; k=rsa; p={dkim_key.public_key}".encode() |
| 900 | + ] |
| 901 | + return [mock_answer] |
| 902 | + raise dns.resolver.NoAnswer() |
| 903 | + |
| 904 | + mock_dns_resolve.side_effect = mock_dns_resolve_func |
| 905 | + |
| 906 | + # Mock successful send |
| 907 | + mock_send_outbound. return_value = { "[email protected]": { "delivered": True}} |
| 908 | + |
| 909 | + # Send the message |
| 910 | + outbound.send_message(message) |
| 911 | + |
| 912 | + # Verify DNS was queried for DKIM record |
| 913 | + assert mock_dns_resolve.called |
| 914 | + |
| 915 | + # Verify message was sent (not marked for retry) |
| 916 | + message.refresh_from_db() |
| 917 | + recipient = message.recipients.first() |
| 918 | + assert recipient.delivery_status == enums.MessageDeliveryStatusChoices.SENT |
| 919 | + assert mock_send_outbound.called |
| 920 | + |
| 921 | + @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True) |
| 922 | + @patch("core.mda.signing.dns.resolver.resolve") |
| 923 | + @patch("core.mda.outbound.send_outbound_message") |
| 924 | + def test_dkim_verification_failure_marks_for_retry( |
| 925 | + self, mock_send_outbound, mock_dns_resolve, mailbox_sender |
| 926 | + ): |
| 927 | + """Test that DKIM verification failure marks recipients for retry.""" |
| 928 | + # Create a message with external recipient |
| 929 | + thread = factories.ThreadFactory() |
| 930 | + factories.ThreadAccessFactory( |
| 931 | + mailbox=mailbox_sender, |
| 932 | + thread=thread, |
| 933 | + role=enums.ThreadAccessRoleChoices.EDITOR, |
| 934 | + ) |
| 935 | + sender_contact = factories.ContactFactory(mailbox=mailbox_sender) |
| 936 | + message = factories.MessageFactory( |
| 937 | + thread=thread, |
| 938 | + sender=sender_contact, |
| 939 | + is_draft=False, |
| 940 | + is_sender=True, |
| 941 | + subject="Test DKIM", |
| 942 | + ) |
| 943 | + |
| 944 | + private_key, public_key = generate_dkim_key(key_size=1024) |
| 945 | + _dkim_key = models.DKIMKey.objects.create( |
| 946 | + selector="testselector", |
| 947 | + private_key=private_key, |
| 948 | + public_key=public_key, |
| 949 | + key_size=1024, |
| 950 | + is_active=True, |
| 951 | + domain=mailbox_sender.domain, |
| 952 | + ) |
| 953 | + |
| 954 | + # Prepare and sign the message |
| 955 | + domain_name = mailbox_sender.domain.name |
| 956 | + raw_mime = f"From: test@{domain_name}\r\nTo: [email protected]\r\nSubject: Test\r\n\r\nBody\r\n". encode() |
| 957 | + signature_header = sign_message_dkim(raw_mime, mailbox_sender.domain) |
| 958 | + signed_mime = signature_header + b"\r\n" + raw_mime |
| 959 | + |
| 960 | + # Create blob with signed message |
| 961 | + blob = mailbox_sender.create_blob( |
| 962 | + content=signed_mime, content_type="message/rfc822" |
| 963 | + ) |
| 964 | + message.blob = blob |
| 965 | + message.save() |
| 966 | + |
| 967 | + # Add external recipient |
| 968 | + external_contact = factories.ContactFactory( |
| 969 | + mailbox=mailbox_sender, email="[email protected]" |
| 970 | + ) |
| 971 | + recipient = factories.MessageRecipientFactory( |
| 972 | + message=message, |
| 973 | + contact=external_contact, |
| 974 | + type=models.MessageRecipientTypeChoices.TO, |
| 975 | + ) |
| 976 | + |
| 977 | + # Mock DNS to fail (no DKIM record found) |
| 978 | + mock_dns_resolve.side_effect = dns.resolver.NoAnswer() |
| 979 | + |
| 980 | + # Send the message |
| 981 | + outbound.send_message(message) |
| 982 | + |
| 983 | + # Verify DNS was queried |
| 984 | + assert mock_dns_resolve.called |
| 985 | + |
| 986 | + # Verify message was NOT sent |
| 987 | + assert not mock_send_outbound.called |
| 988 | + |
| 989 | + # Verify recipient was marked for retry |
| 990 | + recipient.refresh_from_db() |
| 991 | + assert recipient.delivery_status == enums.MessageDeliveryStatusChoices.RETRY |
| 992 | + assert recipient.retry_at is not None |
| 993 | + assert "DKIM verification failed" in recipient.delivery_message |
| 994 | + |
| 995 | + @override_settings(MESSAGES_DKIM_VERIFY_OUTGOING=True) |
| 996 | + @patch("core.mda.signing.dns.resolver.resolve") |
| 997 | + @patch("core.mda.outbound.deliver_inbound_message") |
| 998 | + def test_dkim_verification_skipped_for_internal_recipients( |
| 999 | + self, mock_deliver_inbound, mock_dns_resolve, mailbox_sender |
| 1000 | + ): |
| 1001 | + """Test that DKIM verification is skipped for internal recipients.""" |
| 1002 | + # Create a message with internal recipient |
| 1003 | + thread = factories.ThreadFactory() |
| 1004 | + factories.ThreadAccessFactory( |
| 1005 | + mailbox=mailbox_sender, |
| 1006 | + thread=thread, |
| 1007 | + role=enums.ThreadAccessRoleChoices.EDITOR, |
| 1008 | + ) |
| 1009 | + sender_contact = factories.ContactFactory(mailbox=mailbox_sender) |
| 1010 | + message = factories.MessageFactory( |
| 1011 | + thread=thread, |
| 1012 | + sender=sender_contact, |
| 1013 | + is_draft=False, |
| 1014 | + is_sender=True, |
| 1015 | + subject="Test DKIM", |
| 1016 | + ) |
| 1017 | + |
| 1018 | + # Create blob with message |
| 1019 | + domain_name = mailbox_sender.domain.name |
| 1020 | + raw_mime = f"From: test@{domain_name}\r\nTo: internal@{domain_name}\r\nSubject: Test\r\n\r\nBody\r\n".encode() |
| 1021 | + blob = mailbox_sender.create_blob( |
| 1022 | + content=raw_mime, content_type="message/rfc822" |
| 1023 | + ) |
| 1024 | + message.blob = blob |
| 1025 | + message.save() |
| 1026 | + |
| 1027 | + # Add internal recipient (same domain) |
| 1028 | + # Create mailbox with matching local_part |
| 1029 | + internal_mailbox = factories.MailboxFactory( |
| 1030 | + domain=mailbox_sender.domain, local_part="internal" |
| 1031 | + ) |
| 1032 | + internal_contact = factories.ContactFactory( |
| 1033 | + mailbox=internal_mailbox, email=f"internal@{domain_name}" |
| 1034 | + ) |
| 1035 | + factories.MessageRecipientFactory( |
| 1036 | + message=message, |
| 1037 | + contact=internal_contact, |
| 1038 | + type=models.MessageRecipientTypeChoices.TO, |
| 1039 | + ) |
| 1040 | + |
| 1041 | + # Mock internal delivery |
| 1042 | + mock_deliver_inbound.return_value = True |
| 1043 | + |
| 1044 | + # Send the message |
| 1045 | + outbound.send_message(message) |
| 1046 | + |
| 1047 | + # Verify DNS was NOT queried (DKIM verification skipped for internal) |
| 1048 | + assert not mock_dns_resolve.called |
| 1049 | + |
| 1050 | + # Verify internal delivery was attempted |
| 1051 | + assert mock_deliver_inbound.called |
0 commit comments