Skip to content

Commit 85a8585

Browse files
authored
✨(dkim) add an optional DKIM verification just before sending emails (#434)
This will catch the case where people try to send email before having correctly configured their DNS, and would also avoid unwanted sends of messages not supposed to go out.
1 parent b77f4aa commit 85a8585

File tree

4 files changed

+313
-3
lines changed

4 files changed

+313
-3
lines changed

src/backend/core/mda/outbound.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
create_reply_message,
2222
parse_email_message,
2323
)
24-
from core.mda.signing import sign_message_dkim
24+
from core.mda.signing import sign_message_dkim, verify_message_dkim
2525
from core.mda.smtp import send_smtp_mail
2626

2727
logger = logging.getLogger(__name__)
@@ -424,6 +424,28 @@ def _mark_delivered(
424424
external_recipients.add(recipient_email)
425425

426426
if external_recipients:
427+
# Verify DKIM signature if enabled (only for external recipients)
428+
if settings.MESSAGES_DKIM_VERIFY_OUTGOING:
429+
sender_domain = message.sender.mailbox.domain
430+
431+
if not verify_message_dkim(blob_content):
432+
error_msg = (
433+
f"DKIM verification failed for domain {sender_domain.name}"
434+
)
435+
logger.warning(
436+
"DKIM verification failed for message %s (domain: %s), marking recipients for retry",
437+
message.id,
438+
sender_domain.name,
439+
)
440+
for recipient_email in external_recipients:
441+
_mark_delivered(recipient_email, False, False, error_msg, True)
442+
return
443+
logger.info(
444+
"DKIM verification successful for message %s (domain: %s)",
445+
message.id,
446+
sender_domain.name,
447+
)
448+
427449
try:
428450
statuses = send_outbound_message(
429451
external_recipients, message, blob_content

src/backend/core/mda/signing.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
"""Handles DKIM signing of email messages."""
1+
"""Handles DKIM signing and verification of email messages."""
22

33
import base64
44
import logging
55
from typing import Optional
66

7+
import dns.resolver
78
from cryptography.hazmat.primitives import serialization
89
from cryptography.hazmat.primitives.asymmetric import rsa
910
from dkim import sign as dkim_sign
11+
from dkim import verify as dkim_verify
1012

1113
from core.enums import DKIMAlgorithmChoices
1214

@@ -113,3 +115,60 @@ def sign_message_dkim(raw_mime_message: bytes, maildomain) -> Optional[bytes]:
113115
except Exception as e: # pylint: disable=broad-exception-caught
114116
logger.error("Error during DKIM signing for domain %s: %s", domain, e)
115117
return None
118+
119+
120+
def verify_message_dkim(raw_mime_message: bytes) -> bool:
121+
"""Verify a DKIM signature on a raw MIME message using public DNS.
122+
123+
This verifies that the DKIM signature will pass validation when the receiving
124+
server checks it via DNS, ensuring the signature is valid and the DNS records
125+
are correctly configured.
126+
127+
Args:
128+
raw_mime_message: The raw bytes of the MIME message with DKIM signature.
129+
130+
Returns:
131+
True if the DKIM signature is valid, False otherwise.
132+
"""
133+
try:
134+
# Create a DNS function that performs actual DNS lookups
135+
def get_dns_txt(fqdn, **kwargs):
136+
# Convert FQDN to string if it's bytes
137+
fqdn_str = fqdn.decode("ascii") if isinstance(fqdn, bytes) else fqdn
138+
# Remove trailing dot if present
139+
if fqdn_str.endswith("."):
140+
fqdn_str = fqdn_str[:-1]
141+
142+
try:
143+
# Query DNS for TXT records
144+
answers = dns.resolver.resolve(fqdn_str, "TXT", lifetime=10)
145+
# Combine all TXT record strings (TXT records can be split across multiple strings)
146+
txt_values = []
147+
for answer in answers:
148+
# answer.strings is a list of bytes, join them
149+
txt_value = b"".join(answer.strings)
150+
txt_values.append(txt_value)
151+
152+
# Return the first TXT record value (DKIM should only have one)
153+
if txt_values:
154+
return txt_values[0]
155+
except (
156+
dns.resolver.NXDOMAIN,
157+
dns.resolver.NoAnswer,
158+
dns.resolver.NoNameservers,
159+
):
160+
# Domain or record doesn't exist
161+
logger.warning("DNS lookup error for %s", fqdn_str)
162+
return None
163+
except dns.resolver.Timeout:
164+
logger.warning("DNS timeout while looking up DKIM record: %s", fqdn_str)
165+
return None
166+
167+
return None
168+
169+
# Verify the DKIM signature using public DNS
170+
return dkim_verify(raw_mime_message, dnsfunc=get_dns_txt)
171+
172+
except Exception as e: # pylint: disable=broad-exception-caught
173+
logger.error("Error during DKIM verification: %s", e, exc_info=True)
174+
return False

src/backend/core/tests/mda/test_outbound.py

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Tests for the core.mda.outbound module."""
2-
# pylint: disable=unused-argument
2+
# pylint: disable=unused-argument,too-many-lines
33

44
import threading
55
import time
@@ -13,6 +13,7 @@
1313

1414
from core import enums, factories, models
1515
from core.mda import outbound
16+
from core.mda.signing import generate_dkim_key, sign_message_dkim
1617

1718
SCHEMA_CUSTOM_ATTRIBUTES = {
1819
"$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -827,3 +828,224 @@ def test_prepare_outbound_message_with_only_signature(
827828
"Best regards,<br>John Doe<br>Software Engineer<br>Engineering</p>"
828829
in content
829830
)
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

src/backend/messages/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,13 @@ class Base(Configuration):
331331
"stmessages", environ_name="MESSAGES_DKIM_DEFAULT_SELECTOR", environ_prefix=None
332332
)
333333

334+
# DKIM verification settings
335+
MESSAGES_DKIM_VERIFY_OUTGOING = values.BooleanValue(
336+
default=False,
337+
environ_name="MESSAGES_DKIM_VERIFY_OUTGOING",
338+
environ_prefix=None,
339+
)
340+
334341
# Technical domain for DNS records (MX, SPF, DKIM hosting)
335342
MESSAGES_TECHNICAL_DOMAIN = values.Value(
336343
"localhost", environ_name="MESSAGES_TECHNICAL_DOMAIN", environ_prefix=None

0 commit comments

Comments
 (0)