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",