Fix MIME-structure of HTML emails
authorMagnus Hagander <magnus@hagander.net>
Wed, 7 Jan 2026 20:25:53 +0000 (21:25 +0100)
committerMagnus Hagander <magnus@hagander.net>
Wed, 7 Jan 2026 20:45:51 +0000 (21:45 +0100)
To render properly in particular on Apple Mail, HTML mails with inline
attachments need the structure:

multipart/alternate
  text/plain
  multipart/related
    text/html
    image/png

In particular, the cid-linked images must be part of the same
multipart/related section as the HTML, otherwise they *also* end up at
the bottom of the email as an attachment.

To do this, separate out the idea of html attachments from other
attachments. We currently don't have any other types of attachments, but
since we might in the future, we shouldn't disable that functionality.

Diagnosed by Tobias Bussmann <t.bussmann@gmx.net>
Tested by Daniel Gustafsson <daniel@yesql.se>

pgweb/mailqueue/util.py
pgweb/news/util.py

index 2ef25b3d21eb73931d4eecfa40f93d25e6977be5..a119f8749eb2d67177272a80c333b0a86862363d 100644 (file)
@@ -25,12 +25,43 @@ _utf8_charset.header_encoding = charset.QP
 _utf8_charset.body_encoding = charset.QP
 
 
-def send_simple_mail(sender, receiver, subject, msgtxt, attachments=None, usergenerated=False, cc=None, replyto=None, sendername=None, receivername=None, messageid=None, suppress_auto_replies=True, is_auto_reply=False, htmlbody=None, headers={}, staggertype=None, stagger=None):
-    # attachment format, each is a tuple of (name, mimetype,contents)
-    # content should be *binary* and not base64 encoded, since we need to
-    # use the base64 routines from the email library to get a properly
-    # formatted output message
-    msg = MIMEMultipart()
+def _add_attachments(attachments, msg):
+    for a in attachments:
+        main, sub = a['contenttype'].split('/')
+        part = MIMENonMultipart(main, sub)
+        part.set_payload(a['content'])
+        part.add_header('Content-Disposition', a.get('disposition', 'attachment; filename="%s"' % a['filename']))
+        if 'id' in a:
+            part.add_header('Content-ID', a['id'])
+
+        encoders.encode_base64(part)
+        msg.attach(part)
+
+
+def send_simple_mail(sender, receiver, subject, msgtxt, attachments=None, usergenerated=False, cc=None, replyto=None, sendername=None, receivername=None, messageid=None, suppress_auto_replies=True, is_auto_reply=False, htmlbody=None, headers={}, staggertype=None, stagger=None, htmlattachments=None):
+    if htmlbody:
+        mpart = MIMEMultipart("alternative")
+        mpart.attach(MIMEText(msgtxt, _charset=_utf8_charset))
+        if htmlattachments:
+            # HTML with attachments go in as a separate part that's multipart/related
+            hpart = MIMEMultipart("related")
+            hpart.attach(MIMEText(htmlbody, 'html', _charset=_utf8_charset))
+            _add_attachments(htmlattachments, hpart)
+            mpart.attach(hpart)
+        else:
+            # Just HTML with nothing more
+            mpart.attach(MIMEText(htmlbody, 'html', _charset=_utf8_charset))
+    else:
+        # Just a plaintext body, so append it directly
+        mpart = MIMEText(msgtxt, _charset='utf-8')
+
+    if attachments:
+        msg = MIMEMultipart()
+        msg.attach(mpart)
+        _add_attachments(attachments, msg)
+    else:
+        msg = mpart
+
     msg['Subject'] = subject
     msg['To'] = _encoded_email_header(receivername, receiver)
     msg['From'] = _encoded_email_header(sendername, sender)
@@ -61,27 +92,6 @@ def send_simple_mail(sender, receiver, subject, msgtxt, attachments=None, userge
         else:
             msg.add_header(h, v)
 
-    if htmlbody:
-        mpart = MIMEMultipart("alternative")
-        mpart.attach(MIMEText(msgtxt, _charset=_utf8_charset))
-        mpart.attach(MIMEText(htmlbody, 'html', _charset=_utf8_charset))
-        msg.attach(mpart)
-    else:
-        # Just a plaintext body, so append it directly
-        msg.attach(MIMEText(msgtxt, _charset='utf-8'))
-
-    if attachments:
-        for a in attachments:
-            main, sub = a['contenttype'].split('/')
-            part = MIMENonMultipart(main, sub)
-            part.set_payload(a['content'])
-            part.add_header('Content-Disposition', a.get('disposition', 'attachment; filename="%s"' % a['filename']))
-            if 'id' in a:
-                part.add_header('Content-ID', a['id'])
-
-            encoders.encode_base64(part)
-            msg.attach(part)
-
     with transaction.atomic():
         if staggertype and stagger:
             # Don't send a second one too close after another one of this class.
index 8e9693f11e0aadc121b4016bada22965f2b1c25a..6d4a08458e24e20fe830288f99272eda0f5ccc38 100644 (file)
@@ -97,7 +97,7 @@ def send_news_email(news):
         receivername=settings.NEWS_MAIL_RECEIVER_NAME,
         messageid=messageid,
         htmlbody=html,
-        attachments=attachments,
+        htmlattachments=attachments,
         headers=headers,
         staggertype='news',
         stagger=timedelta(minutes=30),