import re
from abc import ABC, abstractmethod
from email.mime.base import MIMEBase
from functools import lru_cache
from typing import Dict, List, Optional, Tuple, Union
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template import Context, Engine, Template
from django.template.loader import get_template, render_to_string
from django.template.loader_tags import BlockNode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils import translation
from django.utils.functional import keep_lazy_text
from django.utils.html import _strip_once
from cdh.core.settings import CDH_EMAIL_THEME_SETTINGS, \
CDH_EMAIL_PLAIN_FALLBACK_TEMPLATE, CDH_EMAIL_HTML_FALLBACK_TEMPLATE
@keep_lazy_text
def _strip_tags(value) -> str:
"""Return the given HTML with all tags stripped, and leading/trailing
whitespace stripped but with line breaks preserved.
:meta private:
"""
value = str(value)
# Manually replace <br> tags; to preserve intended newlines _strip_once
# just strips it but doesn't actually add a newline for rendering. Thus:
# 'hello<br/>world' would become 'helloworld' instead of 'hello\nworld'. The
# \n in the regex is to make sure we don't add _two_ newlines if someone
# actually puts a newline after a <br>
value = re.sub(r"<br ?/?>\n?", "\n", value)
# Strip HTML tags
# Note: in typical case this loop executes _strip_once once. Loop condition
# is redundant, but helps to reduce number of executions of _strip_once.
while "<" in value and ">" in value:
new_value = _strip_once(value)
if value.count("<") == new_value.count("<"):
# _strip_once wasn't able to detect more tags.
break
value = new_value
# Go over every line and strip
# This is needed because the stripping above doesn't account for indenting
# from the HTML structure
ret = ""
for line in value.split("\n"):
ret += line.strip()
# Add back the newline we split on
ret += '\n'
# Remove any remaining leading/trailing whitespace (often from tags)
# before returning
return ret.strip()
[docs]class BaseEmail(ABC):
"""Base class for all email classes.
These params mostly correspond to Django's Message, so read those docs
for more details
About render/template contexts:
When not supplied with both an HTML and a plain text email, the missing one
will be automatically generated. However, it will not use render contexts
of the other variant. For example, if you leave the plain text version to
be automatically generated, the plain text version will use plain_context
and not html_context.
About theme settings:
The HTML template has some styling configuration to tweak the appearance of
the email. You can specify any changes on this class, but in most cases it's
better to apply app-wide changes using the CDH_EMAIL_THEME_SETTINGS config
value in settings.py
About fallback templates:
These templates are used when generating a missing variant. For example,
if you supply a plain text template only, html_fallback_template will
be used as the base template for the generated HTML version.
When your app uses a custom base template, it's best to set this template
globally using the settings.py settings. It's provided on the class only
if your app uses multiple base templates.
HTML base templates MUST have a block called 'content' and are encouraged to
have 'sender', 'banner' and 'footer' blocks.
Plain base templates use template vars instead of blocks, but the same
requirements apply on those vars as well.
"""
[docs] def __init__(
self,
to: Union[str, List[str]],
subject: str,
language: str = 'nl',
from_email: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
attachments: Optional[List[Union[MIMEBase, Tuple[str, str,
str]]]] = None,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
reply_to: Optional[str] = None,
context: Optional[Dict] = None,
plain_context: Optional[Dict] = None,
html_context: Optional[Dict] = None,
theme_settings: Optional[Dict] = None,
html_fallback_template: str = CDH_EMAIL_HTML_FALLBACK_TEMPLATE,
plain_fallback_template: str = CDH_EMAIL_PLAIN_FALLBACK_TEMPLATE
):
"""
:param to: a list of recipients, can be plain email or formatted ("John
Doe <j.doe@example.org>")
:type to: list of str
:param str subject: the email subject
:param str language: the language used during rendering the email. Uses
Django's i18n framework
:param from_email: the From: email address. Uses settings.EMAIL_FROM if
omitted
:type from_email: str or None
:param headers: any additional SMTP headers to be used
:type headers: dict or None
:param attachments: a list of email attachments to be sent along.
:type attachments: list of MimeBase or Tuple
:param cc: a list of recipients to put as CC:
:type cc: list of str or None
:param bcc: a list of recipients to put ad BCC:
:type bcc: list of str or None
:param reply_to: an email to be set as REPLY_TO: (not set if left empty)
:type reply_to: list of str or None
:param dict context: any template context needed for both the templates
:param dict html_context: any template context needed by the HTML template,
will override values in context if both have them
:param dict plain_context: any template context needed by the plain
template, will override values in context if
both have them.
:param dict theme_settings: a dict of overrides for the styling of the
HTML email does not need to contain all
values, only the ones you want to override.
:param str html_fallback_template: the base template for HTML emails used
for generating an HTML version of a
plain text email
:param str plain_fallback_template: the base template for plain text emails
used for generating a plain text version
of an HTML email
"""
if not isinstance(to, list):
to = [to]
self.to = to
self.cc = cc
self.bcc = bcc
self.reply_to = reply_to
self.subject = subject
self.from_email = from_email or settings.EMAIL_FROM
self.language = language
self.headers = headers
self.attachments = attachments or []
self.context = context or {}
self.plain_context = plain_context or {}
self.html_context = html_context or {}
self.theme_settings = CDH_EMAIL_THEME_SETTINGS.copy()
if theme_settings:
self.apply_theme_settings(theme_settings)
self.html_fallback_template = html_fallback_template
self.plain_fallback_template = plain_fallback_template
[docs] def apply_theme_settings(self, theme_settings: dict) -> None:
"""Applies theme settings
:param dict theme_settings: a dict of overrides for the styling
of the HTML email does not need to
contain all values, only the ones you
want to override.
"""
self.theme_settings.update(theme_settings)
[docs] def attach(self, filename, content=None, mimetype=None) -> None:
"""Attach a new attachment to this email
:param filename: either a string of the filename, or a MIMEBase object
representing the entire file
:type filename: str or MIMEBase
:param content: If filename is a string, the contents of the file.
Ignored otherwise
:type content: str or None
:param mimetype: If filename is a string, the mimetype of the file.
Ignored otherwise
:type mimetype: str or None
"""
if isinstance(filename, MIMEBase):
self.attachments.append(filename)
else:
self.attachments.append((filename, content, mimetype))
[docs] def send(self, connection=None, fail_silently=True) -> int:
"""Sends the email
If sending multiple emails in a row, it's recommended to create a
connection yourself and use them on all emails for performance reasons.
:param connection: a Django email backend to send the mail with. If
omitted, the default backend will be used
:param bool fail_silently: whether errors during sending should be
suppressed
:return: number of emails sent
:rtype: int
"""
old_lang = translation.get_language()
translation.activate(self.language)
email = EmailMultiAlternatives(
to=self.to,
subject=self.subject,
from_email=self.from_email,
reply_to=self.reply_to,
cc=self.cc,
bcc=self.bcc,
body=self._get_plain_body(),
connection=connection,
attachments=self.attachments,
headers=self.headers,
)
email.attach_alternative(
self._get_html_body(),
'text/html'
)
translation.activate(old_lang)
return email.send(fail_silently)
def _get_plain_context(self) -> dict:
context = self.context.copy()
context.update(self.plain_context)
return context
@abstractmethod
def _get_html_context(self) -> dict:
"""Overrides must add the following keys:
has_sender, has_banner and has_footer
"""
context = self.context.copy()
context.update(self.html_context)
context['theme'] = self.theme_settings
return context
@abstractmethod
def _get_plain_body(self) -> str:
pass
@abstractmethod
def _get_html_body(self) -> str:
pass
[docs]class TemplateEmail(BaseEmail):
"""Regular Django template files based emails
One of the two templates is required. If one is missing, it will be
generated from the other.
"""
[docs] def __init__(
self,
*args,
html_template: Optional[str] = None,
plain_template: Optional[str] = None,
**kwargs
):
"""
:param str html_template: the HTML template to send
:param str plain_template: the plain text template to send
:param to: a list of recipients, can be plain email or formatted ("John
Doe <j.doe@example.org>")
:type to: list of str
:param str subject: the email subject
:param str language: the language used during rendering the email. Uses
Django's i18n framework
:param from_email: the From: email address. Uses settings.EMAIL_FROM if
omitted
:type from_email: str or None
:param headers: any additional SMTP headers to be used
:type headers: dict or None
:param attachments: a list of email attachments to be sent along.
:type attachments: list of MimeBase or Tuple
:param cc: a list of recipients to put as CC:
:type cc: list of str or None
:param bcc: a list of recipients to put ad BCC:
:type bcc: list of str or None
:param reply_to: an email to be set as REPLY_TO: (not set if left empty)
:type reply_to: list of str or None
:param dict context: any template context needed for both the templates
:param dict html_context: any template context needed by the HTML template,
will override values in context if both have them
:param dict plain_context: any template context needed by the plain
template, will override values in context if
both have them.
:param dict theme_settings: a dict of overrides for the styling of the
HTML email does not need to contain all
values, only the ones you want to override.
:param str html_fallback_template: the base template for HTML emails used
for generating an HTML version of a
plain text email
:param str plain_fallback_template: the base template for plain text emails
used for generating a plain text version
of an HTML email
"""
super().__init__(*args, **kwargs)
if html_template is None and plain_template is None:
raise ValueError("No email templates supplied!")
self.html_template = html_template
self.plain_template = plain_template
self._html_blocks = None
def _get_html_blocks(self) -> Dict[str, BlockNode]:
"""Helper method to extract the individual content block nodes from the
supplied HTML template"""
if self._html_blocks or not self._has_html_body():
return self._html_blocks
self._html_blocks = {
'sender': None,
'banner': None,
'content': None,
'footer': None,
}
html_template = get_template(self.html_template)
try:
base = list(html_template.template)[0].nodelist
except IndexError:
raise Exception("Invalid mail html template loaded!")
for node in base:
if isinstance(node, BlockNode) and node.name in ['sender',
'banner',
'content',
'footer']:
self._html_blocks[node.name] = node
return self._html_blocks
def _get_html_context(self) -> dict:
context = super()._get_html_context()
if blocks := self._get_html_blocks():
context['has_sender'] = blocks['sender'] is not None
context['has_banner'] = blocks['banner'] is not None
context['has_footer'] = blocks['footer'] is not None
return context
def _get_plain_body(self) -> Optional[str]:
if self.plain_template:
return render_to_string(
self.plain_template,
self._get_plain_context()
)
# If we don't have a Plain template, we're going to build our own!
if self._has_html_body():
# Render the content blocks individually and strip the HTML tags
# from them
blocks = {
name: "\n"+_strip_tags(
node.render(
Context(self._get_plain_context())
)
)
for name, node in self._get_html_blocks().items() if node
}
return render_to_string(
self.plain_fallback_template,
blocks
).strip()
return None
def _has_html_body(self) -> bool:
return self.html_template is not None
def _get_html_body(self) -> str:
context = self._get_html_context()
if self._has_html_body():
return render_to_string(
self.html_template,
context
)
# If we don't have an HTML template, we're going to build our own!
# And paste in the plain content
engine = Engine.get_default()
template = engine.from_string(
"{% extends '" + self.html_fallback_template + "' %}"
"{% block content %}{{ plain_content|linebreaks }}{% endblock %}"
)
context['plain_content'] = self._get_plain_body()
return template.render(Context(context))
[docs]class CTEVarDef:
"""Descriptor class for user-usable variables in a Custom Template Email
This class serves two roles:
- Provide information for help text generation
- Provide a default value when rendering the preview
"""
[docs] def __init__(self, name: str, help_text: str = None, preview_value=None):
"""
:param str name: The variable name
:param help_text: a short description of what this var will output
(optional)
:type help_text: str or None
:param preview_value: a placeholder value that will be inserted when
rendering the preview (optional)
"""
self.name = name
self.help_text = help_text
self.preview_value = preview_value
[docs]class CTETagPackage:
"""Configuration class for loading template tag packages
This class serves two roles:
- Providing information for loading template tag packages in the template
- Provide information for help text generation
All packages need to be importable by the Django rendering engine
"""
[docs] def __init__(
self,
package: str,
tags: List[Tuple[
str,
Optional[list],
Optional[str],
]]):
"""
:param str package: the name of the package to load
:param tags: a list of 3-tuples; The tuple should contain:
- The name of the usable tag inside the package
- A list of arguments that tag accepts
- A help string explaining what the tag does
"""
self.package = package
self.tags = tags
[docs]class BaseCustomTemplateEmail(BaseEmail):
"""Email class for sending HTML emails using user supplied HTML templates
DO NOT USE THIS CLASS FOR 'HARDCODED' EMAILS. Use :class:`.TemplateEmail`
instead.
BE CAREFUL WHAT YOU EXPOSE TO THE USER. This method itself is safe, as
everything is run in a sandbox. However, template tags can have (nasty)
side effects outside the sandbox. Also, some vars might just expose more
than you thought.
Unlike TemplateEmail, this class should be extended and not be used
directly. It uses class variables, which should not be set on an instance
level.
:param user_variable_defs: a list of user usable variables
:type user_variable_defs: list of :class:`CTEVarDef`
:param template_tag_packages: a list of template tag packages to load
:type template_tag_packages: list of :class:`CTETagPackage`
"""
user_variable_defs: List[CTEVarDef] = []
template_tag_packages: List[CTETagPackage] = []
[docs] def __init__(
self,
*args,
contents: str,
sender: Optional[str] = None,
banner: Optional[str] = None,
footer: Optional[str] = None,
**kwargs
):
"""
:param to: a list of recipients, can be plain email or formatted ("John
Doe <j.doe@example.org>")
:type to: list of str
:param str subject: the email subject
:param str language: the language used during rendering the email. Uses
Django's i18n framework
:param from_email: the From: email address. Uses settings.EMAIL_FROM if
omitted
:type from_email: str or None
:param headers: any additional SMTP headers to be used
:type headers: dict or None
:param attachments: a list of email attachments to be sent along.
:type attachments: list of MimeBase or Tuple
:param cc: a list of recipients to put as CC:
:type cc: list of str or None
:param bcc: a list of recipients to put ad BCC:
:type bcc: list of str or None
:param reply_to: an email to be set as REPLY_TO: (not set if left empty)
:type reply_to: list of str or None
:param dict context: any template context needed for both the templates
:param dict html_context: any template context needed by the HTML template,
will override values in context if both have them
:param dict plain_context: any template context needed by the plain
template, will override values in context if
both have them.
:param dict theme_settings: a dict of overrides for the styling of the
HTML email does not need to contain all
values, only the ones you want to override.
:param str html_fallback_template: the base template for HTML emails used
for generating an HTML version of a
plain text email
:param str plain_fallback_template: the base template for plain text emails
used for generating a plain text version
of an HTML email
"""
super().__init__(*args, **kwargs)
self.contents = contents
self.banner = banner
self.sender = sender
self.footer = footer
[docs] @classmethod
@keep_lazy_text
def help_text(cls) -> str:
"""A (marked_safe) string describing which vars and tags can be used
in this template. Intended to be used as a formfield help_text"""
help_text = ""
if cls.user_variable_defs:
help_text += "<strong>"
help_text += _('core.mail.custom.help_text.variables')
help_text += "</strong><br/>"
for var in cls.user_variable_defs:
help_text += "<code>{{ " + var.name + " }}</code>"
if var.help_text:
help_text += f": {var.help_text}"
help_text += "<br/>"
if cls.template_tag_packages:
if cls.user_variable_defs:
help_text += "<br/>"
help_text += "<strong>"
help_text += _('core.mail.custom.help_text.tags')
help_text += "</strong><br/>"
for package in cls.template_tag_packages:
for tag in package.tags:
help_text += "<code>{% " + tag[0]
if tag[1]:
for arg in tag[1]:
help_text += f" {arg}"
help_text += " %}</code>"
if tag[2]:
help_text += f": {tag[2]}"
help_text += "<br/>"
return mark_safe(help_text)
@lru_cache
def _get_template_from_string(self, string: str) -> Template:
engine = Engine.get_default()
return engine.from_string(string)
@property
def _has_sender(self) -> bool:
return bool(self.sender)
@property
def _has_banner(self) -> bool:
return bool(self.banner)
@property
def _has_footer(self) -> bool:
return bool(self.footer)
[docs] def render_preview(self):
"""Returns a rendered HTML document as would be sent as the HTML
content.
Uses the variable defaults as defined if nothing was supplied through
the context param.
"""
return self._get_html_body(True)
def _get_html_context(self) -> dict:
context = super()._get_html_context()
context['has_sender'] = self._has_sender
context['has_banner'] = self._has_banner
context['has_footer'] = self._has_footer
return context
def _generate_template_block(self, name, contents) -> str:
template = "{% block " + name + " %}"
template += contents
template += "{% endblock %}"
return template
def _generate_template_str(self) -> str:
template = "{% extends '" + self.html_fallback_template + "' %}"
for tag in self.template_tag_packages:
template += "{% load " + tag.package + " %}"
template += self._generate_template_block('content', self.contents)
if self._has_sender:
template += self._generate_template_block('sender', self.sender)
if self._has_banner:
template += self._generate_template_block('banner', self.banner)
if self._has_footer:
template += self._generate_template_block('footer', self.footer)
return template
def _get_html_body(self, preview=False) -> str:
template = self._get_template_from_string(
self._generate_template_str()
)
context = self._get_html_context()
if preview:
for var in self.user_variable_defs:
# Only apply the default if we don't have any value in the
# context already. Some previews are capable of adding some
# more specifc context, and we don't want to overwrite those.
if var.name not in context or not context[var.name]:
context[var.name] = var.preview_value
return template.render(
Context(context)
)
def _get_plain_part(self, content: str) -> str:
ret = self._get_template_from_string(content).render(
Context(self._get_plain_context())
)
return "\n" + _strip_tags(ret)
def _get_plain_body(self) -> str:
context = {
'content': self._get_plain_part(self.contents)
}
if self._has_sender:
context['sender'] = self._get_plain_part(self.sender)
if self._has_banner:
context['banner'] = self._get_plain_part(self.banner)
if self._has_footer:
context['footer'] = self._get_plain_part(self.footer)
return render_to_string(
self.plain_fallback_template,
context
).strip()