Add pre_send and post_send signals

Closes #8
This commit is contained in:
medmunds
2016-05-12 21:18:04 -07:00
parent d4f6ffbb41
commit f8eafba0df
7 changed files with 303 additions and 3 deletions

View File

@@ -4,8 +4,9 @@ from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc 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 ..message import AnymailStatus
from ..signals import pre_send, post_send
from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting 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. anticipated failures that should be suppressed in fail_silently mode.
""" """
message.anymail_status = AnymailStatus() message.anymail_status = AnymailStatus()
if not self.run_pre_send(message): # (might modify message)
return False # cancel send without error
if not message.recipients(): if not message.recipients():
return False return False
payload = self.build_message_payload(message, self.send_defaults) payload = self.build_message_payload(message, self.send_defaults)
# FUTURE: if pre-send-signal OK...
response = self.post_to_esp(payload, message) response = self.post_to_esp(payload, message)
message.anymail_status.esp_response = response message.anymail_status.esp_response = response
recipient_status = self.parse_recipient_status(response, payload, message) recipient_status = self.parse_recipient_status(response, payload, message)
message.anymail_status.set_recipient_status(recipient_status) 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) self.raise_for_recipient_status(message.anymail_status, response, payload, message)
# FUTURE: post-send signal
return True 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): def build_message_payload(self, message, defaults):
"""Returns a payload that will allow message to be sent via the ESP. """Returns a payload that will allow message to be sent via the ESP.

View File

@@ -125,6 +125,10 @@ class AnymailSerializationError(AnymailError, TypeError):
super(AnymailSerializationError, self).__init__(message, *args, **kwargs) super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
class AnymailCancelSend(AnymailError):
"""Pre-send signal receiver can raise to prevent message send"""
class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation): class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
"""Exception when a webhook cannot be validated. """Exception when a webhook cannot be validated.

View File

@@ -1,6 +1,12 @@
from django.dispatch import Signal 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 # Delivery and tracking events for sent messages
tracking = Signal(providing_args=['event', 'esp_name']) tracking = Signal(providing_args=['event', 'esp_name'])

View File

@@ -10,4 +10,5 @@ Sending email
anymail_additions anymail_additions
templates templates
tracking tracking
signals
exceptions exceptions

152
docs/sending/signals.rst Normal file
View File

@@ -0,0 +1,152 @@
.. _signals:
Pre- and post-send signals
==========================
Anymail provides :ref:`pre-send <pre-send-signal>` and :ref:`post-send <post-send-signal>`
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 <email> 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 <esp-send-status>` for all messages.
For example, you could implement your own ESP logging dashboard
(perhaps combined with Anymail's :ref:`event-tracking webhooks <event-tracking>`):
.. 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 <anymail.message.AnymailMessage.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).

View File

@@ -27,8 +27,14 @@ class TestBackendTestCase(SimpleTestCase, AnymailTestMixin):
# Simple message useful for many tests # Simple message useful for many tests
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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 @staticmethod
def get_send_params(): def get_send_params():
"""Returns the params for the most recent "send api" call"""
return recorded_send_params[-1] return recorded_send_params[-1]

112
tests/test_send_signals.py Normal file
View File

@@ -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)