Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email templates #1023

Merged
merged 15 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ephios/core/dynamic_preferences_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class EventTypeRegistry(PerInstancePreferenceRegistry):
@global_preferences_registry.register
class OrganizationName(StringPreference):
name = "organization_name"
verbose_name = _("Organization name")
verbose_name = _("Organization display name")
default = ""
section = general_global_section
required = False
Expand Down
16 changes: 12 additions & 4 deletions ephios/core/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,21 @@ def notification_type(self):

@property
def subject(self):
"""The subject of the notification."""
return self.notification_type.get_subject(self)

def as_plaintext(self):
return self.notification_type.as_plaintext(self)
@property
def body(self):
"""The body text of the notification."""
return self.notification_type.get_body(self)

def as_html(self):
"""The notification rendered as HTML."""
return self.notification_type.as_html(self)

def get_url(self):
return self.notification_type.get_url(self)
def as_plaintext(self):
"""The notification rendered as plaintext."""
return self.notification_type.as_plaintext(self)

def get_actions(self):
return self.notification_type.get_actions(self)
106 changes: 106 additions & 0 deletions ephios/core/services/mail/cid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import logging
import os
import re
from email.mime.image import MIMEImage
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart

logger = logging.getLogger(__name__)


# The following code is based on the pretix implementation [source]
# for embedding cid images in emails.
# It is released under the Apache License 2.0.
# If you did not receive a copy of the license, please refer to
# https://www.apache.org/licenses/LICENSE-2.0.html
#
# source:
# https://github.com/pretix/pretix/blob/a08272571b7b67a3f41e02cf05af8183e3f94a02/src/pretix/base/services/mail.py


class CustomEmail(EmailMultiAlternatives):
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.

If the mimetype is message/rfc822, content may be an
email.Message or EmailMessage object, as well as a str.
"""
basetype, subtype = mimetype.split("/", 1)
if basetype == "multipart" and isinstance(content, SafeMIMEMultipart):
return content
return super()._create_mime_attachment(content, mimetype)


def replace_images_with_cid_paths(body_html):
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
for image in email.findAll("img"):
original_image_src = image["src"]
try:
cid_id = "image_%s" % cid_images.index(original_image_src)
except ValueError:
cid_images.append(original_image_src)
cid_id = "image_%s" % (len(cid_images) - 1)
image["src"] = "cid:%s" % cid_id
return str(email), cid_images
else:
return body_html, []


def attach_cid_images(msg, cid_images, verify_ssl=True):
if cid_images and len(cid_images) > 0:
msg.mixed_subtype = "mixed"
for key, image in enumerate(cid_images):
cid = "image_%s" % key
try:
mime_image = convert_image_to_cid(image, cid, verify_ssl)
if mime_image:
msg.attach(mime_image)
except:
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))


def encoder_linelength(msg):
"""
RFC1341 mandates that base64 encoded data may not be longer than 76 characters per line
https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html section 5.2
"""
orig = msg.get_payload(decode=True).replace(b"\n", b"").replace(b"\r", b"")
max_length = 76
pieces = []
for i in range(0, len(orig), max_length):
chunk = orig[i : i + max_length]
pieces.append(chunk)
msg.set_payload(b"\r\n".join(pieces))


def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
image_src = image_src.strip()
try:
if image_src.startswith("data:image/"):
image_type, image_content = image_src.split(",", 1)
image_type = re.findall(r"data:image/(\w+);base64", image_type)[0]
mime_image = MIMEImage(image_content, _subtype=image_type, _encoder=encoder_linelength)
mime_image.add_header("Content-Transfer-Encoding", "base64")
elif image_src.startswith("data:"):
logger.exception("ERROR creating MIME element %s[%s]" % (cid_id, image_src))
return None
else:
# replaced normalize_image_url with these two lines
if "://" not in image_src:
image_src = urljoin(settings.GET_SITE_URL(), image_src)
path = urlparse(image_src).path
guess_subtype = os.path.splitext(path)[1][1:]
response = requests.get(image_src, verify=verify_ssl)
mime_image = MIMEImage(response.content, _subtype=guess_subtype)
mime_image.add_header("Content-ID", "<%s>" % cid_id)
return mime_image
except:
logger.exception("ERROR creating mime_image %s[%s]" % (cid_id, image_src))
return None
67 changes: 67 additions & 0 deletions ephios/core/services/mail/send.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import logging

from css_inline import css_inline
from django.conf import settings
from django.core.mail import SafeMIMEMultipart, SafeMIMEText

from ephios.core.services.mail.cid import (
CustomEmail,
attach_cid_images,
replace_images_with_cid_paths,
)

logger = logging.getLogger(__name__)


def send_mail(
to,
subject,
plaintext,
html=None,
from_email=None,
cc=None,
bcc=None,
is_autogenerated=True,
):
headers = {}
if is_autogenerated:
headers["Auto-Submitted"] = "auto-generated"
# https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmail/ced68690-498a-4567-9d14-5c01f974d8b1
headers["X-Auto-Response-Suppress"] = "OOF, NRN, AutoReply, RN"

email = CustomEmail(
to=to,
subject=subject,
body=prepare_plaintext(plaintext),
headers=headers,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
cc=cc,
bcc=bcc,
)
if html:
html_part = prepare_html_part(html)
email.attach_alternative(html_part, "multipart/related")
email.send()


def prepare_plaintext(plaintext):
"""
Prepare the given plaintext for inclusion in the email.
* Replace newlines with CRLF
"""
return plaintext.replace("\n", "\r\n")


def prepare_html_part(html):
"""
Transform the given rendered HTML into a multipart/related MIME part.
* Inline CSS
* replace image URLs with cid: URLs
"""
inliner = css_inline.CSSInliner()
html = inliner.inline(html)
html_part = SafeMIMEMultipart(_subtype="related", encoding=settings.DEFAULT_CHARSET)
cid_html, cid_images = replace_images_with_cid_paths(html)
html_part.attach(SafeMIMEText(cid_html, "html", settings.DEFAULT_CHARSET))
attach_cid_images(html_part, cid_images, verify_ssl=True)
return html_part
17 changes: 9 additions & 8 deletions ephios/core/services/notifications/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import traceback

from django.conf import settings
from django.core.mail import EmailMultiAlternatives, mail_admins
from django.core.mail import mail_admins
from django.utils.translation import gettext_lazy as _
from webpush import send_user_notification

from ephios.core.models.users import Notification
from ephios.core.services.mail.send import send_mail

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -94,13 +95,13 @@ def _get_mailaddress(cls, notification):

@classmethod
def send(cls, notification):
email = EmailMultiAlternatives(
send_mail(
to=[cls._get_mailaddress(notification)],
subject=notification.subject,
body=notification.as_plaintext(),
plaintext=notification.as_plaintext(),
html=notification.as_html(),
is_autogenerated=True,
)
email.attach_alternative(notification.as_html(), "text/html")
email.send()


class WebPushNotificationBackend(AbstractNotificationBackend):
Expand All @@ -111,11 +112,11 @@ class WebPushNotificationBackend(AbstractNotificationBackend):
def send(cls, notification):
payload = {
"head": str(notification.subject),
"body": notification.as_plaintext(),
"body": notification.body,
"icon": "/static/ephios/img/ephios-symbol-red.svg",
}
if url := notification.get_url():
payload["url"] = url
if actions := notification.get_actions():
payload["url"] = actions[0][1]
send_user_notification(user=notification.user, payload=payload, ttl=1000)


Expand Down
Loading