From f8eafba0dfcf05648b546016b470af9009fa1af9 Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 12 May 2016 21:18:04 -0700 Subject: [PATCH] Add pre_send and post_send signals Closes #8 --- anymail/backends/base.py | 25 +++++- anymail/exceptions.py | 4 + anymail/signals.py | 6 ++ docs/sending/index.rst | 1 + docs/sending/signals.rst | 152 ++++++++++++++++++++++++++++++++++ tests/test_general_backend.py | 6 ++ tests/test_send_signals.py | 112 +++++++++++++++++++++++++ 7 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 docs/sending/signals.rst create mode 100644 tests/test_send_signals.py diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 7d0f8e3..877f6db 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -4,8 +4,9 @@ from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc -from ..exceptions import AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused +from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused from ..message import AnymailStatus +from ..signals import pre_send, post_send from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting @@ -105,22 +106,40 @@ class AnymailBaseBackend(BaseEmailBackend): anticipated failures that should be suppressed in fail_silently mode. """ message.anymail_status = AnymailStatus() + if not self.run_pre_send(message): # (might modify message) + return False # cancel send without error + if not message.recipients(): return False payload = self.build_message_payload(message, self.send_defaults) - # FUTURE: if pre-send-signal OK... response = self.post_to_esp(payload, message) message.anymail_status.esp_response = response recipient_status = self.parse_recipient_status(response, payload, message) message.anymail_status.set_recipient_status(recipient_status) + self.run_post_send(message) # send signal before raising status errors self.raise_for_recipient_status(message.anymail_status, response, payload, message) - # FUTURE: post-send signal return True + def run_pre_send(self, message): + """Send pre_send signal, and return True if message should still be sent""" + try: + pre_send.send(self.__class__, message=message, esp_name=self.esp_name) + return True + except AnymailCancelSend: + return False # abort without causing error + + def run_post_send(self, message): + """Send post_send signal to all receivers""" + results = post_send.send_robust( + self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name) + for (receiver, response) in results: + if isinstance(response, Exception): + raise response + def build_message_payload(self, message, defaults): """Returns a payload that will allow message to be sent via the ESP. diff --git a/anymail/exceptions.py b/anymail/exceptions.py index ff4fe10..2c765bd 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -125,6 +125,10 @@ class AnymailSerializationError(AnymailError, TypeError): super(AnymailSerializationError, self).__init__(message, *args, **kwargs) +class AnymailCancelSend(AnymailError): + """Pre-send signal receiver can raise to prevent message send""" + + class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation): """Exception when a webhook cannot be validated. diff --git a/anymail/signals.py b/anymail/signals.py index 4b6b92c..273b939 100644 --- a/anymail/signals.py +++ b/anymail/signals.py @@ -1,6 +1,12 @@ from django.dispatch import Signal +# Outbound message, before sending +pre_send = Signal(providing_args=['message', 'esp_name']) + +# Outbound message, after sending +post_send = Signal(providing_args=['message', 'status', 'esp_name']) + # Delivery and tracking events for sent messages tracking = Signal(providing_args=['event', 'esp_name']) diff --git a/docs/sending/index.rst b/docs/sending/index.rst index 9243446..922fa8e 100644 --- a/docs/sending/index.rst +++ b/docs/sending/index.rst @@ -10,4 +10,5 @@ Sending email anymail_additions templates tracking + signals exceptions diff --git a/docs/sending/signals.rst b/docs/sending/signals.rst new file mode 100644 index 0000000..a4d785a --- /dev/null +++ b/docs/sending/signals.rst @@ -0,0 +1,152 @@ +.. _signals: + +Pre- and post-send signals +========================== + +Anymail provides :ref:`pre-send ` and :ref:`post-send ` +signals you can connect to trigger actions whenever messages are sent through an Anymail backend. + +Be sure to read Django's `listening to signals`_ docs for information on defining +and connecting signal receivers. + +.. _listening to signals: + https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals + + +.. _pre-send-signal: + +Pre-send signal +--------------- + +You can use Anymail's :data:`~anymail.signals.pre_send` signal to examine +or modify messages before they are sent. +For example, you could implement your own email suppression list: + +.. code-block:: python + + from anymail.exceptions import AnymailCancelSend + from anymail.signals import pre_send + from django.dispatch import receiver + from email.utils import parseaddr + + from your_app.models import EmailBlockList + + @receiver(pre_send) + def filter_blocked_recipients(sender, message, **kwargs): + # Cancel the entire send if the from_email is blocked: + if not ok_to_send(message.from_email): + raise AnymailCancelSend("Blocked from_email") + # Otherwise filter the recipients before sending: + message.to = [addr for addr in message.to if ok_to_send(addr)] + message.cc = [addr for addr in message.cc if ok_to_send(addr)] + + def ok_to_send(addr): + # This assumes you've implemented an EmailBlockList model + # that holds emails you want to reject... + name, email = parseaddr(addr) # just want the part + try: + EmailBlockList.objects.get(email=email) + return False # in the blocklist, so *not* OK to send + except EmailBlockList.DoesNotExist: + return True # *not* in the blocklist, so OK to send + +Any changes you make to the message in your pre-send signal receiver +will be reflected in the ESP send API call, as shown for the filtered +"to" and "cc" lists above. Note that this will modify the original +EmailMessage (not a copy)---be sure this won't confuse your sending +code that created the message. + +If you want to cancel the message altogether, your pre-send receiver +function can raise an :exc:`~anymail.signals.AnymailCancelSend` exception, +as shown for the "from_email" above. This will silently cancel the send +without raising any other errors. + + +.. data:: anymail.signals.pre_send + + Signal delivered before each EmailMessage is sent. + + Your pre_send receiver must be a function with this signature: + + .. function:: def my_pre_send_handler(sender, message, **kwargs): + + (You can name it anything you want.) + + :param class sender: + The Anymail backend class processing the message. + This parameter is required by Django's signal mechanism, + and despite the name has nothing to do with the *email message's* sender. + (You generally won't need to examine this parameter.) + :param ~django.core.mail.EmailMessage message: + The message being sent. If your receiver modifies the message, those + changes will be reflected in the ESP send call. + :param str esp_name: + The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun"). + :param \**kwargs: + Required by Django's signal mechanism (to support future extensions). + :raises: + :exc:`anymail.exceptions.AnymailCancelSend` if your receiver wants + to cancel this message without causing errors or interrupting a batch send. + + + +.. _post-send-signal: + +Post-send signal +---------------- + +You can use Anymail's :data:`~anymail.signals.post_send` signal to examine +messages after they are sent. This is useful to centralize handling of +the :ref:`sent status ` for all messages. + +For example, you could implement your own ESP logging dashboard +(perhaps combined with Anymail's :ref:`event-tracking webhooks `): + +.. code-block:: python + + from anymail.signals import post_send + from django.dispatch import receiver + + from your_app.models import SentMessage + + @receiver(post_send) + def log_sent_message(sender, message, status, esp_name, **kwargs): + # This assumes you've implemented a SentMessage model for tracking sends. + # status.recipients is a dict of email: status for each recipient + for email, recipient_status in status.recipients.items(): + SentMessage.objects.create( + esp=esp_name, + message_id=recipient_status.message_id, # might be None if send failed + email=email, + subject=message.subject, + status=recipient_status.status, # 'sent' or 'rejected' or ... + ) + + +.. data:: anymail.signals.post_send + + Signal delivered after each EmailMessage is sent. + + If you register multiple post-send receivers, Anymail will ensure that + all of them are called, even if one raises an error. + + Your post_send receiver must be a function with this signature: + + .. function:: def my_post_send_handler(sender, message, status, esp_name, **kwargs): + + (You can name it anything you want.) + + :param class sender: + The Anymail backend class processing the message. + This parameter is required by Django's signal mechanism, + and despite the name has nothing to do with the *email message's* sender. + (You generally won't need to examine this parameter.) + :param ~django.core.mail.EmailMessage message: + The message that was sent. You should not modify this in a post-send receiver. + :param ~anymail.message.AnymailStatus status: + The normalized response from the ESP send call. (Also available as + :attr:`message.anymail_status `.) + :param str esp_name: + The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun"). + :param \**kwargs: + Required by Django's signal mechanism (to support future extensions). diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index ec827c1..8f892e7 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -27,8 +27,14 @@ class TestBackendTestCase(SimpleTestCase, AnymailTestMixin): # Simple message useful for many tests self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + @staticmethod + def get_send_count(): + """Returns number of times "send api" has been called this test""" + return len(recorded_send_params) + @staticmethod def get_send_params(): + """Returns the params for the most recent "send api" call""" return recorded_send_params[-1] diff --git a/tests/test_send_signals.py b/tests/test_send_signals.py new file mode 100644 index 0000000..f43073e --- /dev/null +++ b/tests/test_send_signals.py @@ -0,0 +1,112 @@ +from django.dispatch import receiver + +from anymail.backends.test import TestBackend +from anymail.exceptions import AnymailCancelSend, AnymailRecipientsRefused +from anymail.message import AnymailRecipientStatus +from anymail.signals import pre_send, post_send + +from .test_general_backend import TestBackendTestCase + + +class TestPreSendSignal(TestBackendTestCase): + """Test Anymail's pre_send signal""" + + def test_pre_send(self): + """Pre-send receivers invoked for each message, before sending""" + @receiver(pre_send, weak=False) + def handle_pre_send(sender, message, esp_name, **kwargs): + self.assertEqual(self.get_send_count(), 0) # not sent yet + self.assertEqual(sender, TestBackend) + self.assertEqual(message, self.message) + self.assertEqual(esp_name, "Test") # the TestBackend's ESP is named "Test" + self.receiver_called = True + self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) + + self.receiver_called = False + self.message.send() + self.assertTrue(self.receiver_called) + self.assertEqual(self.get_send_count(), 1) # sent now + + def test_modify_message_in_pre_send(self): + """Pre-send receivers can modify message""" + @receiver(pre_send, weak=False) + def handle_pre_send(sender, message, esp_name, **kwargs): + message.to = [email for email in message.to if not email.startswith('bad')] + message.body += "\nIf you have received this message in error, ignore it" + self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) + + self.message.to = ['legit@example.com', 'bad@example.com'] + self.message.send() + params = self.get_send_params() + self.assertEqual([email.email for email in params['to']], # params['to'] is ParsedEmail list + ['legit@example.com']) + self.assertRegex(params['text_body'], + r"If you have received this message in error, ignore it$") + + def test_cancel_in_pre_send(self): + """Pre-send receiver can cancel the send""" + @receiver(pre_send, weak=False) + def cancel_pre_send(sender, message, esp_name, **kwargs): + raise AnymailCancelSend("whoa there") + self.addCleanup(pre_send.disconnect, receiver=cancel_pre_send) + + self.message.send() + self.assertEqual(self.get_send_count(), 0) # send API not called + + +class TestPostSendSignal(TestBackendTestCase): + """Test Anymail's post_send signal""" + + def test_post_send(self): + """Post-send receiver called for each message, after sending""" + @receiver(post_send, weak=False) + def handle_post_send(sender, message, status, esp_name, **kwargs): + self.assertEqual(self.get_send_count(), 1) # already sent + self.assertEqual(sender, TestBackend) + self.assertEqual(message, self.message) + self.assertEqual(status.status, {'sent'}) + self.assertEqual(status.message_id, 1) # TestBackend default message_id + self.assertEqual(status.recipients['to@example.com'].status, 'sent') + self.assertEqual(status.recipients['to@example.com'].message_id, 1) + self.assertEqual(esp_name, "Test") # the TestBackend's ESP is named "Test" + self.receiver_called = True + self.addCleanup(post_send.disconnect, receiver=handle_post_send) + + self.receiver_called = False + self.message.send() + self.assertTrue(self.receiver_called) + + def test_post_send_exception(self): + """All post-send receivers called, even if one throws""" + @receiver(post_send, weak=False) + def handler_1(sender, message, status, esp_name, **kwargs): + raise ValueError("oops") + self.addCleanup(post_send.disconnect, receiver=handler_1) + + @receiver(post_send, weak=False) + def handler_2(sender, message, status, esp_name, **kwargs): + self.handler_2_called = True + self.addCleanup(post_send.disconnect, receiver=handler_2) + + self.handler_2_called = False + with self.assertRaises(ValueError): + self.message.send() + self.assertTrue(self.handler_2_called) + + def test_rejected_recipients(self): + """Post-send receiver even if AnymailRecipientsRefused is raised""" + @receiver(post_send, weak=False) + def handle_post_send(sender, message, status, esp_name, **kwargs): + self.receiver_called = True + self.addCleanup(post_send.disconnect, receiver=handle_post_send) + + self.message.test_response = { + 'recipient_status': { + 'to@example.com': AnymailRecipientStatus(message_id=None, status='rejected') + } + } + + self.receiver_called = False + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + self.assertTrue(self.receiver_called)