DKIM Failed Only for HTML Emails: Root Cause and Fix

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.py
  • de_vindazo/spontaneousmail/management/commands/sendmailingprofilelocal.py
  • de_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