diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
index 3b1b13f9d..376518087 100644
--- a/src/pretix/base/services/mail.py
+++ b/src/pretix/base/services/mail.py
@@ -54,7 +54,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart
-from django.core.mail.message import SafeMIMEText
+from django.core.mail.message import SafeMIMEText, utf8_charset_qp
from django.db import connection, transaction
from django.db.models import Q
from django.dispatch import receiver
@@ -380,6 +380,8 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
class CustomEmail(EmailMultiAlternatives):
+ encoding = utf8_charset_qp
+
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.
@@ -449,9 +451,9 @@ def mail_send_task(self, **kwargs) -> bool:
# Rewrite all
tags from real URLs or data URLs to inline attachments referred to by content ID
if outgoing_mail.body_html is not None:
- html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
+ html_message = SafeMIMEMultipart(_subtype='related')
html_with_cid, cid_images = replace_images_with_cid_paths(outgoing_mail.body_html)
- html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET))
+ html_message.attach(SafeMIMEText(html_with_cid, 'html', utf8_charset_qp))
attach_cid_images(html_message, cid_images, verify_ssl=True)
email.attach_alternative(html_message, "multipart/related")
diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py
index 9a5a9e66a..e685fdedc 100644
--- a/src/tests/base/test_mail.py
+++ b/src/tests/base/test_mail.py
@@ -34,6 +34,7 @@
import datetime
import os
+import quopri
import re
import socket
from contextlib import contextmanager
@@ -316,8 +317,10 @@ def _extract_html(mail):
if "multipart/related" in mimetype:
for sp in content._payload:
if isinstance(sp, MIMEText):
- return sp._payload
- break
+ if sp.get('content-transfer-encoding').startswith('quoted-printable'):
+ return quopri.decodestring(sp.get_payload()).decode()
+ else:
+ return sp.get_payload()
elif "text/html" in mimetype:
return content
@@ -711,3 +714,29 @@ def test_send_mail_private_ip(res, use_ssl, allow_private_networks, env):
assert m.status == OutgoingMail.STATUS_SENT
else:
assert m.status == OutgoingMail.STATUS_FAILED
+
+
+@pytest.mark.django_db
+def test_mail_encoding(env, mailoutbox):
+ m = OutgoingMail.objects.create(
+ to=['recipient@example.com'],
+ subject='Test',
+ body_plain='Test öäü testtesttest',
+ body_html='Test ÖÄÜ',
+ sender='sender@example.com',
+ )
+ assert m.status == OutgoingMail.STATUS_QUEUED
+ mail_send_task.apply(kwargs={
+ 'outgoing_mail': m.pk,
+ }, max_retries=0)
+ m.refresh_from_db()
+ assert m.status == OutgoingMail.STATUS_SENT
+
+ assert len(mailoutbox) == 1
+ mail_plain = mailoutbox[0].message().as_string()
+
+ assert "ä" not in mail_plain
+ assert "Ä" not in mail_plain
+ assert "Test =C3=B6=C3=A4=C3=BC" in mail_plain
+ assert "Test =C3=96=C3=84=C3=9C" in mail_plain
+ assert max(len(x) for x in mail_plain.splitlines()) <= 76