# SPDX-FileCopyrightText: 2017-present Tobias Kunze
# SPDX-License-Identifier: AGPL-3.0-only WITH LicenseRef-Pretalx-AGPL-3.0-Terms
from django.conf import settings
from django.template.loader import get_template
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import override
from pretalx.common.exceptions import SendMailException
from pretalx.common.templatetags.rich_text import (
render_mail_body,
render_markdown_abslinks,
)
from pretalx.common.text.formatting import (
MODE_HTML,
MODE_PLAIN,
FormattedString,
format_map,
)
from pretalx.mail.domain.context import get_mail_context
from pretalx.mail.models import QueuedMail
def assert_rendered(subject, text, text_html):
"""Construction-time guard: ``subject`` / ``text`` must be
``FormattedString`` (from :func:`format_map`) or ``SafeString``
(from :func:`mark_safe`); ``text_html`` may also be ``None``. Markers
do not survive the DB round-trip, so the persisted send path
(``send_draft``) cannot re-validate."""
for name, value, accept_none in (
("subject", subject, False),
("text", text, False),
("text_html", text_html, True),
):
if accept_none and value is None:
continue
if not isinstance(value, (FormattedString, SafeString)):
optional = " or None" if accept_none else ""
raise TypeError(
f"Mail {name} must be a FormattedString or SafeString{optional}, "
f"got {type(value).__name__}."
)
def get_prefixed_subject(event, subject):
if not (prefix := event.mail_settings["subject_prefix"]):
return subject
if not (prefix.startswith("[") and prefix.endswith("]")):
prefix = f"[{prefix}]"
if subject.startswith(prefix):
return subject
return f"{prefix} {subject}"
[docs]
def render_to_mail(
*,
subject_template,
text_template,
event=None,
locale=None,
safe_extra_context=None,
context_kwargs=None,
):
"""Render raw subject/text strings against the placeholder context
and return an unsaved :class:`QueuedMail`. Use this for ad-hoc
content (system mails, on-the-fly invitations); for organiser-managed
:class:`MailTemplate`s, prefer :func:`render_template_to_mail`.
``event`` may be ``None`` for global system mails (password resets,
organiser team invites). If set, it is injected into the context.
Recipient and envelope fields (``to``, ``reply_to``, ``bcc``,
``template``) are not rendering inputs and are left for the caller
to set on the returned mail before persisting or dispatching.
"""
context_kwargs = {**(context_kwargs or {}), "event": event}
with override(locale):
context = get_mail_context(
safe_extra_context=safe_extra_context, **context_kwargs
)
try:
subject = format_map(subject_template, context, mode=MODE_PLAIN)
text = format_map(text_template, context, mode=MODE_PLAIN)
text_html = render_mail_body(
format_map(text_template, context, mode=MODE_HTML)
)
except KeyError as e:
raise SendMailException(
f"Experienced KeyError when rendering email text: {e!s}"
) from e
if len(subject) > 200:
subject = FormattedString(subject[:198] + "…")
return QueuedMail(
event=event, subject=subject, text=text, text_html=text_html, locale=locale
)
[docs]
def build_trusted_mail(*, event, to, subject, text):
"""Unsaved :class:`QueuedMail` from organiser-final content. No
placeholder rendering — the caller asserts the strings are trusted.
Markdown rendering of the body happens at send time via
:func:`delivery_html_body`'s fallback."""
return QueuedMail(
event=event,
to=to,
subject=mark_safe(subject), # noqa: S308 -- organiser-final content
text=mark_safe(text), # noqa: S308 -- organiser-final content
)
[docs]
def render_template_to_mail(
template, *, locale=None, safe_extra_context=None, context_kwargs=None
):
"""The canonical, safe way to construct QueuedMail objects from a
persisted :class:`MailTemplate`. Returns an unsaved
:class:`QueuedMail` with ``to`` / ``to_users`` unset; the caller picks
:func:`~pretalx.mail.domain.queue.save_draft`,
:func:`~pretalx.mail.domain.send.send_draft`, or
:func:`~pretalx.mail.domain.send.send_transient` next.
For ad-hoc content not backed by a saved template (system mails,
on-the-fly invitations), use :func:`render_to_mail` directly.
"""
if template.pk is None:
raise ValueError(
"render_template_to_mail requires a saved MailTemplate; "
"use render_to_mail for ad-hoc subject/text strings."
)
mail = render_to_mail(
subject_template=template.subject,
text_template=template.text,
event=template.event,
locale=locale,
safe_extra_context=safe_extra_context,
context_kwargs=context_kwargs,
)
mail.template = template
mail.reply_to = template.reply_to
mail.bcc = template.bcc
return mail
def delivery_html_body(mail):
"""Return the sanitised HTML body of ``mail`` — the same markup the
recipient will see, without the outer mail-wrapper layout. The
inner part of :func:`delivery_html`; also used directly by the
organiser outbox / mail-log preview views, so the preview matches
the delivered body byte-for-byte."""
if mail.text_html is not None:
# Already rendered at render_template_to_mail time (markdown +
# bleach via render_mail_body, with user-controlled substitutions
# escaped and wrapped in <span>/<div>). Stored in the DB as a
# plain string; re-mark safe for template rendering.
return mark_safe(mail.text_html) # noqa: S308 -- rendered via render_mail_body at creation
# No placeholder-rendered HTML available — the mail was constructed
# directly (literal string) or the organiser edited the draft body
# after rendering (see ``MailDetailForm.save``). Fall back to the
# legacy markdown/bleach pipeline, which autolinks bare URLs in
# organiser-typed text as before. This path is safe only for text
# that is fully organiser- or system-controlled; user-content
# placeholders are already pre-sanitised in their plain variant so
# they cannot re-inject HTML or markdown links when this fallback
# runs.
return render_markdown_abslinks(mail.text)
def delivery_html(mail):
"""Build the full HTML payload of ``mail`` for SMTP delivery: the
body (via :func:`delivery_html_body`) wrapped in the styled email
layout."""
event = mail.event
sig = None
if event:
sig = event.mail_settings["signature"]
if sig.strip().startswith("-- "):
sig = sig.strip()[3:].strip()
html_context = {
"body": delivery_html_body(mail),
"event": event,
"color": (event.primary_color if event else "")
or settings.DEFAULT_EVENT_PRIMARY_COLOR,
"locale": mail.locale,
"rtl": mail.locale in settings.LANGUAGES_BIDI,
"subject": mail.subject,
"signature": sig,
}
return get_template("mail/mailwrapper.html").render(html_context)
def delivery_text(mail):
"""Build the full plain-text payload of ``mail`` for SMTP delivery:
the text body with the event signature appended."""
event = mail.event
if not event or not event.mail_settings["signature"]:
return mail.text
sig = event.mail_settings["signature"]
if not sig.strip().startswith("-- "):
sig = f"-- \n{sig}"
return f"{mail.text}\n{sig}"