diff --git a/emark/backends.py b/emark/backends.py
index 957369e..f8a5597 100644
--- a/emark/backends.py
+++ b/emark/backends.py
@@ -1,60 +1,78 @@
-import copy
-import smtplib
import uuid
-from django.conf import settings
+from django.core.mail import EmailMessage
from django.core.mail.backends.console import EmailBackend as _EmailBackend
from django.core.mail.backends.smtp import EmailBackend as _SMTPEmailBackend
-from django.core.mail.message import sanitize_address
from emark import models
from emark.message import MarkdownEmail
__all__ = [
"ConsoleEmailBackend",
+ "SMTPEmailBackend",
"TrackingConsoleEmailBackend",
"TrackingSMTPEmailBackend",
]
+class RenderEmailBackendMixin:
+ def send_messages(self, email_messages):
+ self._messages_sent = []
+ for message in email_messages:
+ if isinstance(message, MarkdownEmail):
+ message.render()
+ try:
+ return super().send_messages(email_messages)
+ finally:
+ models.Send.objects.bulk_create(self._messages_sent)
+
+
class TrackingEmailBackendMixin:
"""Add tracking framework to an email backend."""
def send_messages(self, email_messages):
self._messages_sent = []
+ for message in email_messages:
+ if isinstance(message, MarkdownEmail):
+ message.render(tracking_uuid=uuid.uuid4())
try:
return super().send_messages(email_messages)
finally:
models.Send.objects.bulk_create(self._messages_sent)
- def _track_message_clone(self, clone, message):
- if isinstance(clone, MarkdownEmail):
+ def _track_message(self, message: EmailMessage):
+ if isinstance(message, MarkdownEmail):
self._messages_sent.append(
models.Send(
- pk=clone._tracking_uuid,
- from_address=message["From"],
- to_address=message["To"],
- subject=message["Subject"],
- body=clone.body,
- html=clone.html,
- user=getattr(clone, "user", None),
- utm=clone.get_utm_params(**clone.utm_params),
+ pk=message.uuid,
+ from_email=message.from_email,
+ to=message.to,
+ cc=message.cc,
+ bcc=message.bcc,
+ reply_to=message.reply_to,
+ subject=message.subject,
+ body=message.body,
+ html=message.html,
+ user=getattr(message, "user", None),
+ utm=message.get_utm_params(),
)
)
else:
self._messages_sent.append(
models.Send(
- pk=clone._tracking_uuid,
- from_address=message["From"],
- to_address=message["To"],
- subject=message["Subject"],
- body=clone.body,
+ from_email=message.from_email,
+ to=message.to,
+ cc=message.cc,
+ bcc=message.bcc,
+ reply_to=message.reply_to,
+ subject=message.subject,
+ body=message.body,
)
)
-class ConsoleEmailBackend(_EmailBackend):
- """Like the console email backend but only with the plain text body."""
+class ConsoleEmailBackendMixin:
+ """Drop email alternative parts and attachments for the console backend."""
def write_message(self, message):
msg = message.message()
@@ -76,19 +94,26 @@ def write_message(self, message):
return msg
-class TrackingConsoleEmailBackend(TrackingEmailBackendMixin, ConsoleEmailBackend):
+class ConsoleEmailBackend(
+ RenderEmailBackendMixin, ConsoleEmailBackendMixin, _EmailBackend
+):
+ """Like the console email backend but only with the plain text body."""
+
+
+class SMTPEmailBackend(RenderEmailBackendMixin, _SMTPEmailBackend):
+ """SMTP email backend that renders messages before establishing an SMTP transport."""
+
+ pass
+
+
+class TrackingConsoleEmailBackend(
+ TrackingEmailBackendMixin, ConsoleEmailBackendMixin, _EmailBackend
+):
"""Like the console email backend but with click and open tracking."""
def write_message(self, message):
- for recipient in message.recipients():
- clone = copy.copy(message)
- clone.to = [recipient]
- clone.cc = []
- clone.bcc = []
- # enable tracking
- clone._tracking_uuid = uuid.uuid4()
- msg = super().write_message(clone)
- self._track_message_clone(clone, msg)
+ self._track_message(message)
+ return super().write_message(message)
class TrackingSMTPEmailBackend(TrackingEmailBackendMixin, _SMTPEmailBackend):
@@ -101,26 +126,10 @@ class TrackingSMTPEmailBackend(TrackingEmailBackendMixin, _SMTPEmailBackend):
"""
def _send(self, email_message):
- for recipient in email_message.recipients():
- clone = copy.copy(email_message)
- clone.to = [recipient]
- clone.cc = []
- clone.bcc = []
- # enable tracking
- clone._tracking_uuid = uuid.uuid4()
-
- encoding = clone.encoding or settings.DEFAULT_CHARSET
- from_email = sanitize_address(clone.from_email, encoding)
- recipients = [sanitize_address(recipient, encoding)]
- message = clone.message()
- try:
- self.connection.sendmail(
- from_email, recipients, message.as_bytes(linesep="\r\n")
- )
- except smtplib.SMTPException:
- if not self.fail_silently:
- raise
- return False
- else:
- self._track_message_clone(clone, message)
- return True
+ sent = False
+ try:
+ sent = super()._send(email_message)
+ return sent
+ finally:
+ if sent:
+ self._track_message(email_message)
diff --git a/emark/message.py b/emark/message.py
index c846deb..dd34de0 100644
--- a/emark/message.py
+++ b/emark/message.py
@@ -43,7 +43,7 @@ class MarkdownEmail(EmailMultiAlternatives):
base_html_template = "emark/base.html"
template = None
subject = None
- _tracking_uuid = False
+ uuid = False
def __init__(
self,
@@ -60,59 +60,46 @@ def __init__(
self.language = language
self.utm_params = utm_params or {}
self.subject = subject or self.subject
+ self.html = None
+ self.markdown = None
super().__init__(subject=self.subject, **kwargs)
- def get_html(self):
- """Return the rendered HTML version of the email."""
- with translation.override(self.language):
- utm_params = self.get_utm_params()
- context = self.get_context_data(**utm_params)
- context |= utm_params
- template = self.get_template()
- subject = self.get_subject(**context)
-
- markdown_string = self.get_markdown_string(template, context, utm_params)
-
- return self.render_html(
- markdown_string=markdown_string,
- context={
- "subject": subject,
- **context,
- },
- )
-
- def get_body(self, html):
- """Return the parsed plain text version of the rendered HTML email."""
- parser = utils.HTML2TextParser()
- parser.feed(html)
- parser.close()
- body = str(parser)
- if self._tracking_uuid:
- href = reverse("emark:email-detail", kwargs={"pk": self._tracking_uuid})
- href = parse.urljoin(self.get_site_url(), href)
- txt = capfirst(gettext("view in browser"))
- body = f"{txt} <{href}>\n\n" + body
- return body
+ @classmethod
+ def to_user(cls, user=None, context=None, language=None, **kwargs):
+ """Return email with user specific language, context and recipient."""
+ if not user.email:
+ raise ValueError("User has no email address")
+ if not user.is_active:
+ raise ValueError("User is not active")
+ if settings.USE_I18N:
+ try:
+ language = language or user.language
+ except AttributeError as e:
+ raise ValueError(
+ "If your user model does not have a language field,"
+ " you must provide a language."
+ ) from e
+ context = context or {}
+ context |= {
+ "short_name": user.get_short_name(),
+ "user": user,
+ }
+ obj = cls(
+ **{
+ "to": [f'"{user.get_full_name()}" <{user.email}>'],
+ "context": context,
+ "language": language,
+ }
+ | kwargs
+ )
+ obj.user = user
+ return obj
def message(self):
# The connection will call .message while sending the email.
- self.subject = self.get_subject(**self.get_context_data())
- self.html = self.get_html()
- self.body = self.get_body(self.html)
- self.attach_alternative(self.html, "text/html")
+ self.render()
return super().message()
- def get_utm_params(self, **params: {str: str}) -> {str: str}:
- """Return a dictionary of UTM parameters."""
- return (
- conf.get_settings().UTM_PARAMS
- | {
- "utm_campaign": self.get_utm_campaign_name(),
- }
- | self.utm_params
- | params
- )
-
@classmethod
def get_utm_campaign_name(cls):
"""Return the UTM campaign name for this email."""
@@ -128,9 +115,9 @@ def update_url_params(self, url, **params):
url_new_query = parse.urlencode(params)
redirect_url_parts = redirect_url_parts._replace(query=url_new_query)
redirect_url = parse.urlunparse(redirect_url_parts)
- if not self._tracking_uuid:
+ if not self.uuid:
return redirect_url
- tracking_url = reverse("emark:email-click", kwargs={"pk": self._tracking_uuid})
+ tracking_url = reverse("emark:email-click", kwargs={"pk": self.uuid})
tracking_url = parse.urljoin(self.get_site_url(), tracking_url)
tracking_url_parts = parse.urlparse(tracking_url)
tracking_url_parts = tracking_url_parts._replace(
@@ -138,7 +125,7 @@ def update_url_params(self, url, **params):
)
return parse.urlunparse(tracking_url_parts)
- def set_utm_attributes(self, md, **utm):
+ def inject_utm_params(self, md, **utm):
for url in INLINE_LINK_RE.findall(md):
md = md.replace(f"({url})", f"({self.update_url_params(url, **utm)})")
for url in INLINE_HTML_LINK_RE.findall(md):
@@ -147,37 +134,6 @@ def set_utm_attributes(self, md, **utm):
)
return md
- @classmethod
- def to_user(cls, user=None, context=None, language=None, **kwargs):
- """Return email with user specific language, context and recipient."""
- if not user.email:
- raise ValueError("User has no email address")
- if not user.is_active:
- raise ValueError("User is not active")
- if settings.USE_I18N:
- try:
- language = language or user.language
- except AttributeError as e:
- raise ValueError(
- "If your user model does not have a language field,"
- " you must provide a language."
- ) from e
- context = context or {}
- context |= {
- "short_name": user.get_short_name(),
- "user": user,
- }
- obj = cls(
- **{
- "to": [f'"{user.get_full_name()}" <{user.email}>'],
- "context": context,
- "language": language,
- }
- | kwargs
- )
- obj.user = user
- return obj
-
def get_template(self):
if not self.template:
raise ImproperlyConfigured(
@@ -196,22 +152,33 @@ def get_site_url(self):
return parse.urlunparse((protocol, domain, "", "", "", ""))
- def get_context_data(self, **context):
+ def get_utm_params(self) -> {str: str}:
+ """Return a dictionary of UTM parameters."""
+ return (
+ conf.get_settings().UTM_PARAMS
+ | {
+ "utm_campaign": self.get_utm_campaign_name(),
+ }
+ | self.utm_params
+ )
+
+ def get_context_data(self):
"""Return the context data for the email."""
- if self._tracking_uuid:
+ context = {}
+ if self.uuid:
context |= {
- "tracking_uuid": self._tracking_uuid,
+ "tracking_uuid": self.uuid,
"view_in_browser_url": parse.urljoin(
self.get_site_url(),
- reverse("emark:email-detail", kwargs={"pk": self._tracking_uuid}),
+ reverse("emark:email-detail", kwargs={"pk": self.uuid}),
),
"tracking_pixel_url": parse.urljoin(
self.get_site_url(),
- reverse("emark:email-open", kwargs={"pk": self._tracking_uuid}),
+ reverse("emark:email-open", kwargs={"pk": self.uuid}),
),
}
- return self.context | context
+ return context | self.context
def get_subject(self, **context):
"""Return the email's subject."""
@@ -219,13 +186,14 @@ def get_subject(self, **context):
raise ImproperlyConfigured(
f"{self.__class__.__qualname__} is missing a subject."
)
- return self.subject
+ return self.subject % context
- def get_markdown_string(self, template, context, utm):
+ def get_markdown(self, context, utm):
+ template = self.get_template()
markdown_string = loader.get_template(template).render(context)
- return self.set_utm_attributes(markdown_string, **self.get_utm_params(**utm))
+ return self.inject_utm_params(markdown_string, **utm)
- def render_html(self, markdown_string, context):
+ def get_html(self, markdown_string, context):
html_message = markdown.markdown(
markdown_string,
extensions=[
@@ -246,3 +214,34 @@ def render_html(self, markdown_string, context):
cssutils_logging_level=logging.WARNING,
)
return inlined_html
+
+ def get_body(self, html):
+ """Return the parsed plain text version of the rendered HTML email."""
+ parser = utils.HTML2TextParser()
+ parser.feed(html)
+ parser.close()
+ body = str(parser)
+ if self.uuid:
+ href = reverse("emark:email-detail", kwargs={"pk": self.uuid})
+ href = parse.urljoin(self.get_site_url(), href)
+ txt = capfirst(gettext("view in browser"))
+ body = f"{txt} <{href}>\n\n" + body
+ return body
+
+ def render(self, tracking_uuid=None):
+ """Render the email."""
+ if self.html is None:
+ self.uuid = tracking_uuid
+ with translation.override(self.language):
+ utm_params = self.get_utm_params()
+ context = self.get_context_data()
+ context |= utm_params
+ self.subject = self.get_subject(**context)
+ context["subject"] = self.subject
+ self.markdown = self.get_markdown(context, utm_params)
+ self.html = self.get_html(
+ markdown_string=self.markdown,
+ context=context,
+ )
+ self.body = self.get_body(self.html)
+ self.attach_alternative(self.html, "text/html")
diff --git a/emark/migrations/0002_rename_from_address_send_from_email_and_more.py b/emark/migrations/0002_rename_from_address_send_from_email_and_more.py
new file mode 100644
index 0000000..7527c4f
--- /dev/null
+++ b/emark/migrations/0002_rename_from_address_send_from_email_and_more.py
@@ -0,0 +1,57 @@
+import json
+
+from django.db import migrations, models
+
+
+def forwards_func(apps, schema_editor): # pragma: no cover
+ Send = apps.get_model("emark", "Send")
+ for send in Send.objects.all():
+ send.to = json.dumps([send.to])
+ send.save(update_fields=["to"])
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("emark", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="send",
+ old_name="from_address",
+ new_name="from_email",
+ ),
+ migrations.RenameField(
+ model_name="send",
+ old_name="to_address",
+ new_name="to",
+ ),
+ migrations.RunPython(
+ forwards_func,
+ ),
+ migrations.AlterField(
+ model_name="send",
+ name="to",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="send",
+ name="cc",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="send",
+ name="bcc",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="send",
+ name="reply_to",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AlterField(
+ model_name="send",
+ name="from_email",
+ field=models.TextField(max_length=998),
+ ),
+ ]
diff --git a/emark/models.py b/emark/models.py
index 08558b7..7d15e66 100644
--- a/emark/models.py
+++ b/emark/models.py
@@ -18,8 +18,12 @@ class Send(models.Model):
related_name="emark_emails",
null=True,
)
- from_address = models.EmailField()
- to_address = models.EmailField()
+ # In RFC 2822 from is a mailbox-list, but Django only support a single
+ from_email = models.TextField(max_length=998)
+ to = models.JSONField(default=list)
+ cc = models.JSONField(default=list)
+ bcc = models.JSONField(default=list)
+ reply_to = models.JSONField(default=list)
subject = models.TextField(max_length=998) # RFC 2822
body = models.TextField()
html = models.TextField(null=True)
diff --git a/tests/test_backends.py b/tests/test_backends.py
index 5700bba..6924179 100644
--- a/tests/test_backends.py
+++ b/tests/test_backends.py
@@ -20,6 +20,17 @@ def test_write_message(self):
assert "1 more attachment(s) have been omitted." in stdout
+class TestSMTPEmailBackend:
+ def test_send(self, email_message):
+ class TestBackend(backends.SMTPEmailBackend):
+ def _send(self, message):
+ return bool(message.html)
+
+ backend = TestBackend(fail_silently=False)
+ backend.connection = Mock()
+ assert backend.send_messages([email_message]) == 1
+
+
class TestTrackingConsoleEmailBackend:
@pytest.mark.django_db
def test_send(self, email_message):
@@ -32,8 +43,8 @@ def test_send(self, email_message):
with io.StringIO() as stream:
backend = backends.TrackingConsoleEmailBackend(stream=stream)
assert backend.send_messages([email_message]) == 1
- assert Send.objects.count() == 3
- obj = Send.objects.get(to_address="peter.parker@avengers.com")
+ assert Send.objects.count() == 1
+ obj = Send.objects.get()
assert str(obj.uuid) in obj.body
@pytest.mark.django_db
@@ -46,7 +57,7 @@ def test_send__with_user(self, admin_user, email_message):
assert backend.send_messages([email_message]) == 1
assert Send.objects.count() == 1
- obj = Send.objects.get(to_address=admin_user.email)
+ obj = Send.objects.get()
assert obj.user == admin_user
@pytest.mark.django_db
@@ -69,10 +80,9 @@ def test_write_message__native_email__multiple_receipients(self):
with io.StringIO() as stream:
backends.TrackingConsoleEmailBackend(stream=stream).send_messages([msg])
stdout = stream.getvalue()
- assert "To: peter.parker@aol.com" in stdout
assert "To: spiderman@avengers.com" in stdout
- assert "Cc:" not in stdout
- assert Send.objects.count() == 2
+ assert "Cc: peter.parker@aol.com" in stdout
+ assert Send.objects.count() == 1
class TestTrackingSMTPEmailBackend:
@@ -90,9 +100,9 @@ class TestBackend(backends.TrackingSMTPEmailBackend):
backend = TestBackend(fail_silently=False)
backend.connection = Mock()
assert backend.send_messages([email_message]) == 1
- assert backend.connection.sendmail.call_count == 3
- assert Send.objects.count() == 3
- obj = Send.objects.get(to_address="peter.parker@avengers.com")
+ assert backend.connection.sendmail.call_count == 1
+ assert Send.objects.count() == 1
+ obj = Send.objects.get()
assert str(obj.uuid) in obj.body
@pytest.mark.django_db
@@ -112,8 +122,8 @@ class TestBackend(backends.TrackingSMTPEmailBackend):
backend = TestBackend(fail_silently=False)
backend.connection = Mock()
assert backend.send_messages([email_message]) == 1
- assert backend.connection.sendmail.call_count == 3
- assert Send.objects.count() == 3
+ assert backend.connection.sendmail.call_count == 1
+ assert Send.objects.count() == 1
@pytest.mark.django_db
def test_send__with_user(self, admin_user, email_message):
@@ -128,7 +138,7 @@ class TestBackend(backends.TrackingSMTPEmailBackend):
assert backend.send_messages([email_message]) == 1
assert backend.connection.sendmail.call_count == 1
assert Send.objects.count() == 1
- obj = Send.objects.get(to_address=admin_user.email)
+ obj = Send.objects.get(to=[admin_user.email])
assert obj.user == admin_user
@pytest.mark.django_db
@@ -164,9 +174,9 @@ class TestBackend(backends.TrackingSMTPEmailBackend):
backend = TestBackend(fail_silently=True)
backend.connection = Mock()
assert backend.send_messages([email_message]) == 1
- assert backend.connection.sendmail.call_count == 3
- assert Send.objects.count() == 3
- obj = Send.objects.get(to_address="peter.parker@avengers.com")
+ assert backend.connection.sendmail.call_count == 1
+ assert Send.objects.count() == 1
+ obj = Send.objects.get()
assert str(obj.uuid) in obj.body
@pytest.mark.django_db
diff --git a/tests/test_message.py b/tests/test_message.py
index 8f5e1d0..4fd2ed8 100644
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -174,24 +174,21 @@ def test_body(self, email_message):
assert message_text in email_message.body
def test_open_tracking(self, email_message):
- email_message._tracking_uuid = "12341234-1234-1234-1234-123412341234"
- email_message.message()
+ email_message.render("12341234-1234-1234-1234-123412341234")
assert (
'"
in email_message.body
)
def test_open_in_browser__html(self, email_message):
- email_message._tracking_uuid = "12341234-1234-1234-1234-123412341234"
- email_message.message()
+ email_message.render("12341234-1234-1234-1234-123412341234")
assert (
''
in email_message.html
@@ -213,7 +210,7 @@ def test_custom_context(self):
subject="Peanut strikes back",
context=custom_context,
)
- assert email_message.get_context_data(**custom_context) == {
+ assert email_message.get_context_data() == {
"donut_type": "Honey",
"donut_name": "HoneyNuts",
}
@@ -224,7 +221,7 @@ def test_get_template(self):
subject="Peanut strikes back",
)
with pytest.raises(ImproperlyConfigured):
- msg.message()
+ msg.get_template()
def test_get_subject__missing(self):
msg = emark.message.MarkdownEmail(
@@ -233,7 +230,7 @@ def test_get_subject__missing(self):
context={"donut_name": "HoneyNuts", "donut_type": "Honey"},
)
with pytest.raises(ImproperlyConfigured) as e:
- msg.message()
+ msg.get_subject()
assert str(e.value) == "MarkdownEmail is missing a subject."
def test_get_subject(self):
@@ -248,7 +245,7 @@ def test_set_utm_attributes(self):
language="en-US",
context={"donut_name": "HoneyNuts", "donut_type": "Honey"},
)
- email_message.message()
+ email_message.render()
assert (
"This is a link! "
in email_message.body
@@ -288,7 +285,7 @@ def test_update_url_params(self, email_message):
)
def test_update_url_params__tracking_uuid(self, email_message):
- email_message._tracking_uuid = "12341234-1234-1234-1234-123412341234"
+ email_message.uuid = "12341234-1234-1234-1234-123412341234"
assert (
email_message.update_url_params(
"https://localhost:8080/?utm_source=foo",