DKIM Failed Only for HTML Emails: Root Cause and Fix
We had a mail stack test where DKIM passed for plain‑text messages, but failed for HTML emails. This was a blocker because GMX/WEB.DE started rejecting volume mail unless DKIM passed.
Symptoms
- Plain‑text mail: DKIM = pass
- HTML mail: DKIM = fail
Broken HTML mail showed:
Content-Transfer-Encoding: 8bit
Authentication-Results: dkim=fail reason="signature verification failed"
Diagnosis — What We Checked
1) DKIM key / DNS matches?
# Private key exists
sudo ls -l /etc/dkimkeys/default
sudo head -n 2 /etc/dkimkeys/default
# Public key from private key
openssl rsa -in /etc/dkimkeys/default -pubout -outform DER 2>/dev/null | openssl base64 -A
# DNS record
dig +short mail._domainkey.vindazo.de TXT
Result: key and DNS matched.
2) OpenDKIM signs correctly?
sudo opendkim-testkey -d vindazo.de -s mail -k /etc/dkimkeys/default -vv
Result: key OK (only a “key not secure” warning due to 1024‑bit).
3) Postfix altering mail after signing?
sudo postconf -n | grep -E 'content_filter|header_checks|mime_header_checks|nested_header_checks|body_checks|smtp_header_checks|milter'
sudo postconf -M
Result: no extra filters.
4) Only difference between pass and fail?
The only consistent difference was Content‑Transfer‑Encoding: 8bit on HTML mails.
Root Cause
Django generated multipart HTML messages with 8bit encoding.
That allowed subtle body rewriting in transit, which invalidated DKIM.
Fix: Force quoted‑printable for all text/* parts
We introduced a custom mail class that forces QP encoding on all text parts. This prevents 8bit payloads from being modified downstream.
Custom QP class
from email import encoders
from django.core.mail import EmailMultiAlternatives
class QPEmailMultiAlternatives(EmailMultiAlternatives):
def message(self):
msg = super(QPEmailMultiAlternatives, self).message()
self._force_qp(msg)
return msg
def _force_qp(self, msg):
if msg.is_multipart():
for part in msg.get_payload():
self._force_qp(part)
return
if not msg.get_content_type().startswith('text/'):
return
payload = msg.get_payload(decode=True)
if payload is None:
return
msg.set_payload(payload)
encoders.encode_quopri(msg)
Use the class for HTML emails
email = QPEmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=from_email,
to=[recipient],
connection=connection,
headers=headers
)
email.attach_alternative(html_body, "text/html")
email.send()
Where Applied
de_vindazo/mailing/utils.pyde_vindazo/spontaneousmail/management/commands/sendmailingprofilelocal.pyde_vindazo/cyclusemail/management/commands/sendcyclusmaillocal.py
Result (Working Headers)
--===============6473159814107843107==
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit
Content-Transfer-Encoding: quoted-printable
DKIM passed for HTML mail after the change.
Full Timeline (Log)
[1] DKIM fail only on HTML mails
[2] DNS key check: OK
[3] Private/public key match: OK
[4] Postfix filters: none
[5] 8bit CTE only on failing mails
[6] Canonicalization relaxed/relaxed: no effect
[7] Force UTF‑8 in Django: no effect
[8] Force quoted‑printable on text/* parts
[9] DKIM pass on HTML mails
Note: Key Length
Key length is 1024‑bit. It still works but some providers are stricter. Plan a 2048‑bit upgrade when possible.

Comments
Post a Comment