Compare commits

..

2 Commits

Author SHA1 Message Date
Raphael Michel faa97026e1 Tests 2026-07-03 12:14:08 +02:00
Raphael Michel e1736e8d2a Mail setup: Add DKIM + DMARC validation 2026-07-03 11:57:00 +02:00
10 changed files with 185 additions and 32 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ dependencies = [
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-querytagger==0.0.3",
"django-querytagger==0.0.2",
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
@@ -50,6 +50,46 @@
{% endblocktrans %}
</div>
{% endif %}
{% if dkim_warning %}
<div class="alert alert-danger">
<p>
{{ dkim_warning }}
</p>
<p>
{% trans "Your new DKIM record should be set up as a CNAME record like this:" %}
</p>
<pre><code>{{ dkim_hostname }} CNAME {{ dkim_cname }}</code></pre>
<p>
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
</p>
</div>
{% elif dkim_cname %}
<div class="alert alert-success">
{% blocktrans trimmed %}
We found a DKIM record on your domain for this system. Great!
{% endblocktrans %}
</div>
{% endif %}
{% if dmarc_warning %}
<div class="alert alert-danger">
<p>
{{ dmarc_warning }}
</p>
<p>
{% trans "Your new DMARC record could look like this:" %}
</p>
<pre><code>_dmarc.{{ hostname }} TXT "v=DMARC1; p=quarantine; sp=none; adkim=r; aspf=r;"</code></pre>
<p>
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
</p>
</div>
{% elif dkim_cname %}
<div class="alert alert-success">
{% blocktrans trimmed %}
We found a DMARC record on your domain for this system. Great!
{% endblocktrans %}
</div>
{% endif %}
{% if verification %}
<h3>{% trans "Verification" %}</h3>
<p>
@@ -70,7 +110,7 @@
</div>
</div>
{% if spf_warning %}
{% if spf_warning or dkim_warning or dmarc_warning %}
<div class="form-group submit-group">
<a href="" class="btn btn-default btn-save">
{% trans "Cancel" %}
+75 -4
View File
@@ -41,6 +41,30 @@ from pretix.control.forms.mailsetup import SimpleMailForm, SMTPMailForm
logger = logging.getLogger(__name__)
def get_cname_record(hostname):
try:
r = dns.resolver.Resolver()
answers = r.resolve(hostname, 'CNAME')
answers = list(answers)
if len(answers) != 1:
logger.exception('Found multiple CNAME records for {}'.format(hostname))
return
return str(answers[0].target).lower()
except:
logger.exception('Could not fetch CNAME record for {}'.format(hostname))
def get_dmarc_record(hostname):
try:
r = dns.resolver.Resolver()
for resp in r.resolve("_dmarc." + hostname, 'TXT'):
data = b''.join(resp.strings).decode()
if 'DMARC1' in data.strip():
return data
except:
logger.exception("Could not fetch DMARC record for {}".format(hostname))
def get_spf_record(hostname):
try:
r = dns.resolver.Resolver()
@@ -49,7 +73,7 @@ def get_spf_record(hostname):
if data.lower().strip().startswith('v=spf1 '): # RFC7208, section 4.5
return data
except:
logger.exception("Could not fetch SPF record")
logger.exception("Could not fetch SPF record for {}".format(hostname))
def _check_spf_record(not_found_lookup_parts, spf_record, depth):
@@ -168,10 +192,15 @@ class MailSettingsSetupView(TemplateView):
return super().get(request, *args, **kwargs)
session_key = f'sender_mail_verification_code_{self.request.path}_{self.simple_form.cleaned_data.get("mail_from")}'
verify_dns = (
settings.MAIL_CUSTOM_SENDER_SPF_STRING or
(settings.MAIL_CUSTOM_SENDER_DKIM_CNAME and settings.MAIL_CUSTOM_SENDER_DMARC_REQUIRED) or
settings.MAIL_CUSTOM_SENDER_DMARC_REQUIRED
)
allow_save = (
(not settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED or
('verification' in self.request.POST and self.request.POST.get('verification', '') == self.request.session.get(session_key, None))) and
(not settings.MAIL_CUSTOM_SENDER_SPF_STRING or self.request.POST.get('state') == 'save')
(not verify_dns or self.request.POST.get('state') == 'save')
)
if allow_save:
@@ -192,8 +221,8 @@ class MailSettingsSetupView(TemplateView):
spf_warning = None
spf_record = None
hostname = self.simple_form.cleaned_data['mail_from'].split('@')[-1]
if settings.MAIL_CUSTOM_SENDER_SPF_STRING:
hostname = self.simple_form.cleaned_data['mail_from'].split('@')[-1]
spf_record = get_spf_record(hostname)
if not spf_record:
spf_warning = _(
@@ -210,7 +239,43 @@ class MailSettingsSetupView(TemplateView):
'this system in the SPF record.'
)
verification = settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED and not spf_warning
dkim_warning = None
dkim_hostname = None
dkim_cname = None
if settings.MAIL_CUSTOM_SENDER_DKIM_CNAME and settings.MAIL_CUSTOM_SENDER_DKIM_SELECTOR:
dkim_hostname = settings.MAIL_CUSTOM_SENDER_DKIM_SELECTOR + '._domainkey.' + hostname
cname_target = get_cname_record(dkim_hostname)
dkim_cname = settings.MAIL_CUSTOM_SENDER_DKIM_CNAME
if not dkim_cname.endswith("."):
dkim_cname += "."
if "%s" in dkim_cname:
dkim_cname = dkim_cname.replace("%s", hostname.replace(".", "-").lower())
if not cname_target:
dkim_warning = _(
'We could not find a CNAME record pointing to our DKIM key for domain you are trying to use. '
'This means that there is a very high change most of the emails will be rejected or marked as '
'spam. We strongly recommend setting up DKIM through a CNAME record. You can do so through the '
'DNS settings at the provider you registered your domain with.'
)
elif cname_target != dkim_cname:
dkim_warning = _(
'We found a CNAME record for a DKIM key, but it is not pointing to the right location. '
'This means that there is a very high chance most of the emails will be rejected or marked as '
'spam. You should update the DNS settings of your domain.'
)
dmarc_warning = None
dmarc_record = None
if settings.MAIL_CUSTOM_SENDER_DMARC_REQUIRED:
dmarc_record = get_dmarc_record(hostname)
if not dmarc_record:
spf_warning = _(
'We did not find DMARC record for your domain. This means that there is a very high chance '
'most of the emails will be rejected or marked as spam. You should update the DNS settings '
'of your domain.'
)
verification = settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED and not spf_warning and not dkim_warning and not dmarc_warning
if verification:
if 'verification' in self.request.POST:
messages.error(request, _('The verification code was incorrect, please try again.'))
@@ -240,6 +305,12 @@ class MailSettingsSetupView(TemplateView):
'spf_warning': spf_warning,
'spf_record': spf_record,
'spf_key': settings.MAIL_CUSTOM_SENDER_SPF_STRING,
'dkim_warning': dkim_warning,
'dkim_hostname': dkim_hostname,
'dkim_cname': dkim_cname,
'dmarc_warning': dmarc_warning,
'dmarc_record': dmarc_record,
'hostname': hostname,
'recp': self.simple_form.cleaned_data.get('mail_from')
},
using=self.template_engine,
@@ -9,7 +9,7 @@
{% load anonymize_email %}
{% block thetitle %}
{% if messages %}
{{ messages|join:" " }} ::
{{ messages|join:" " }} ::
{% endif %}
{% block title %}{% endblock %}{% if request.resolver_match.url_name != "event.index" %} :: {% endif %}{{ event.name }}
{% endblock %}
@@ -1,7 +1,6 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load l10n %}
{% load static %}
{% load eventurl %}
{% load cache_large %}
{% load money %}
@@ -40,7 +39,6 @@
{% else %}
<meta property="og:url" content="{% abseventurl request.event "presale:event.index" %}" />
{% endif %}
<script type="text/javascript" src="{% static "pretixpresale/js/csrfcookieretry.js" %}"></script>
{% endblock %}
{% block content %}
@@ -6,7 +6,6 @@
{% load money %}
{% load expiresformat %}
{% load eventurl %}
{% load static %}
{% load phone_format %}
{% load rich_text %}
{% load getitem %}
@@ -23,10 +22,6 @@
{% endif %}
{% trans "Order details" %}
{% endblock %}
{% block custom_header %}
{{ block.super }}
<script type="text/javascript" src="{% static "pretixpresale/js/csrfcookieretry.js" %}"></script>
{% endblock %}
{% block content %}
{% if "thanks" in request.GET or "paid" in request.GET %}
<div class="thank-you">
+1 -1
View File
@@ -123,7 +123,7 @@ def widget_css_etag(request, version, **kwargs):
def _use_vite(request):
if getattr(settings, 'PRETIX_WIDGET_VITE', False) or "beta" in request.GET:
if getattr(settings, 'PRETIX_WIDGET_VITE', False):
return True
origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject()
+3
View File
@@ -257,6 +257,9 @@ MAIL_FROM_NOTIFICATIONS = config.get('mail', 'from_notifications', fallback=MAIL
MAIL_FROM_ORGANIZERS = config.get('mail', 'from_organizers', fallback=MAIL_FROM)
MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED = config.getboolean('mail', 'custom_sender_verification_required', fallback=True)
MAIL_CUSTOM_SENDER_SPF_STRING = config.get('mail', 'custom_sender_spf_string', fallback='')
MAIL_CUSTOM_SENDER_DKIM_SELECTOR = config.get('mail', 'custom_sender_dkim_selector', fallback='')
MAIL_CUSTOM_SENDER_DKIM_CNAME = config.get('mail', 'custom_sender_dkim_cname', fallback='')
MAIL_CUSTOM_SENDER_DMARC_REQUIRED = config.getboolean('mail', 'custom_sender_dmarc_required', fallback=False)
MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = config.getboolean('mail', 'custom_smtp_allow_private_networks', fallback=DEBUG)
EMAIL_HOST = config.get('mail', 'host', fallback='localhost')
EMAIL_PORT = config.getint('mail', 'port', fallback=25)
@@ -1,15 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const COOKIE_NAME = "__Host-pretix_csrftoken";
const RELOAD_FLAG = "csrfReloadPerformed";
const hasCookie = document.cookie
.split("; ")
.some((c) => c.startsWith(COOKIE_NAME + "="));
if (!hasCookie && !sessionStorage.getItem(RELOAD_FLAG)) {
sessionStorage.setItem(RELOAD_FLAG, "1");
location.reload();
} else if (hasCookie && sessionStorage.getItem(RELOAD_FLAG)) {
sessionStorage.removeItem(RELOAD_FLAG);
}
});
+63 -2
View File
@@ -159,6 +159,22 @@ class OrganizerTest(SoupTest):
self.orga1.settings.flush()
assert "mail_from" not in self.orga1.settings._cache()
@staticmethod
def _fake_dmarc_record(hostname):
return {
'test.pretix.dev': 'v=DMARC1; p=quarantine; sp=none; adkim=r; aspf=r;',
'bad.pretix.dev': 'BLA',
'none.pretix.dev': None,
}[hostname]
@staticmethod
def _fake_cname_record(hostname):
return {
'pretix._domainkey.test.pretix.dev': 'test-pretix-dev.dkim.pretix.eu.',
'pretix._domainkey.bad.pretix.dev': 'example.org',
'pretix._domainkey.none.pretix.dev': None,
}[hostname]
@staticmethod
def _fake_spf_record(hostname):
return {
@@ -172,9 +188,17 @@ class OrganizerTest(SoupTest):
'spftest.pretix.dev': None,
}[hostname]
@override_settings(MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED=False, MAIL_CUSTOM_SENDER_SPF_STRING="include:spftest.pretix.dev include:test2.pretix.dev")
def test_email_setup_no_verification_spf_success(self):
@override_settings(
MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED=False,
MAIL_CUSTOM_SENDER_SPF_STRING="include:spftest.pretix.dev include:test2.pretix.dev",
MAIL_CUSTOM_SENDER_DKIM_SELECTOR="pretix",
MAIL_CUSTOM_SENDER_DKIM_CNAME="dkim.pretix.eu.",
MAIL_CUSTOM_SENDER_DMARC_REQUIRED=True,
)
def test_email_setup_no_verification_spf_dmarc_dkim_success(self):
self.monkeypatch.setattr("pretix.control.views.mailsetup.get_spf_record", OrganizerTest._fake_spf_record)
self.monkeypatch.setattr("pretix.control.views.mailsetup.get_cname_record", OrganizerTest._fake_cname_record)
self.monkeypatch.setattr("pretix.control.views.mailsetup.get_dmarc_record", OrganizerTest._fake_dmarc_record)
doc = self.post_doc(
'/control/organizer/%s/settings/email/setup' % self.orga1.slug,
{
@@ -213,6 +237,43 @@ class OrganizerTest(SoupTest):
# not yet saved
assert "mail_from" not in self.orga1.settings._cache()
@override_settings(MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED=False,
MAIL_CUSTOM_SENDER_DKIM_SELECTOR="pretix",
MAIL_CUSTOM_SENDER_DKIM_CNAME="dkim.pretix.eu.",
MAIL_CUSTOM_SENDER_SPF_STRING="")
def test_email_setup_no_verification_dkim_warning(self):
self.monkeypatch.setattr("pretix.control.views.mailsetup.get_cname_record", OrganizerTest._fake_cname_record)
doc = self.post_doc(
'/control/organizer/%s/settings/email/setup' % self.orga1.slug,
{
'mode': 'simple',
'simple-mail_from': 'test@bad.pretix.dev',
},
follow=True
)
assert doc.select('.alert-danger')
self.orga1.settings.flush()
# not yet saved
assert "mail_from" not in self.orga1.settings._cache()
@override_settings(MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED=False,
MAIL_CUSTOM_SENDER_DMARC_REQUIRED=True,
MAIL_CUSTOM_SENDER_SPF_STRING="")
def test_email_setup_no_verification_dmarc_warning(self):
self.monkeypatch.setattr("pretix.control.views.mailsetup.get_dmarc_record", OrganizerTest._fake_dmarc_record)
doc = self.post_doc(
'/control/organizer/%s/settings/email/setup' % self.orga1.slug,
{
'mode': 'simple',
'simple-mail_from': 'test@bad.pretix.dev',
},
follow=True
)
assert doc.select('.alert-danger')
self.orga1.settings.flush()
# not yet saved
assert "mail_from" not in self.orga1.settings._cache()
def test_email_setup_smtp(self):
self.monkeypatch.setattr("pretix.base.email.test_custom_smtp_backend", lambda b, a: None)
self.monkeypatch.setattr("socket.gethostbyname", lambda h: "8.8.8.8")