Skip to content

Commit

Permalink
Add pre_send and post_send signals
Browse files Browse the repository at this point in the history
Closes #8
  • Loading branch information
medmunds committed May 13, 2016
1 parent d4f6ffb commit f8eafba
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 3 deletions.
25 changes: 22 additions & 3 deletions anymail/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
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


Expand Down Expand Up @@ -105,22 +106,40 @@ def _send(self, message):
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.
Expand Down
4 changes: 4 additions & 0 deletions anymail/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ def __init__(self, message=None, orig_err=None, *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):
"""Exception when a webhook cannot be validated.
Expand Down
6 changes: 6 additions & 0 deletions anymail/signals.py
Original file line number Diff line number Diff line change
@@ -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'])

Expand Down
1 change: 1 addition & 0 deletions docs/sending/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ Sending email
anymail_additions
templates
tracking
signals
exceptions
152 changes: 152 additions & 0 deletions docs/sending/signals.rst
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions tests/test_general_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ def setUp(self):
# 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]


Expand Down
112 changes: 112 additions & 0 deletions tests/test_send_signals.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit f8eafba

Please sign in to comment.