Skip to content

Commit f8eafba

Browse files
committed
Add pre_send and post_send signals
Closes #8
1 parent d4f6ffb commit f8eafba

File tree

7 files changed

+303
-3
lines changed

7 files changed

+303
-3
lines changed

anymail/backends/base.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from django.core.mail.backends.base import BaseEmailBackend
55
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
66

7-
from ..exceptions import AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
7+
from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
88
from ..message import AnymailStatus
9+
from ..signals import pre_send, post_send
910
from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting
1011

1112

@@ -105,22 +106,40 @@ def _send(self, message):
105106
anticipated failures that should be suppressed in fail_silently mode.
106107
"""
107108
message.anymail_status = AnymailStatus()
109+
if not self.run_pre_send(message): # (might modify message)
110+
return False # cancel send without error
111+
108112
if not message.recipients():
109113
return False
110114

111115
payload = self.build_message_payload(message, self.send_defaults)
112-
# FUTURE: if pre-send-signal OK...
113116
response = self.post_to_esp(payload, message)
114117
message.anymail_status.esp_response = response
115118

116119
recipient_status = self.parse_recipient_status(response, payload, message)
117120
message.anymail_status.set_recipient_status(recipient_status)
118121

122+
self.run_post_send(message) # send signal before raising status errors
119123
self.raise_for_recipient_status(message.anymail_status, response, payload, message)
120-
# FUTURE: post-send signal
121124

122125
return True
123126

127+
def run_pre_send(self, message):
128+
"""Send pre_send signal, and return True if message should still be sent"""
129+
try:
130+
pre_send.send(self.__class__, message=message, esp_name=self.esp_name)
131+
return True
132+
except AnymailCancelSend:
133+
return False # abort without causing error
134+
135+
def run_post_send(self, message):
136+
"""Send post_send signal to all receivers"""
137+
results = post_send.send_robust(
138+
self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name)
139+
for (receiver, response) in results:
140+
if isinstance(response, Exception):
141+
raise response
142+
124143
def build_message_payload(self, message, defaults):
125144
"""Returns a payload that will allow message to be sent via the ESP.
126145

anymail/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ def __init__(self, message=None, orig_err=None, *args, **kwargs):
125125
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
126126

127127

128+
class AnymailCancelSend(AnymailError):
129+
"""Pre-send signal receiver can raise to prevent message send"""
130+
131+
128132
class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
129133
"""Exception when a webhook cannot be validated.
130134

anymail/signals.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from django.dispatch import Signal
22

33

4+
# Outbound message, before sending
5+
pre_send = Signal(providing_args=['message', 'esp_name'])
6+
7+
# Outbound message, after sending
8+
post_send = Signal(providing_args=['message', 'status', 'esp_name'])
9+
410
# Delivery and tracking events for sent messages
511
tracking = Signal(providing_args=['event', 'esp_name'])
612

docs/sending/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ Sending email
1010
anymail_additions
1111
templates
1212
tracking
13+
signals
1314
exceptions

docs/sending/signals.rst

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
.. _signals:
2+
3+
Pre- and post-send signals
4+
==========================
5+
6+
Anymail provides :ref:`pre-send <pre-send-signal>` and :ref:`post-send <post-send-signal>`
7+
signals you can connect to trigger actions whenever messages are sent through an Anymail backend.
8+
9+
Be sure to read Django's `listening to signals`_ docs for information on defining
10+
and connecting signal receivers.
11+
12+
.. _listening to signals:
13+
https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals
14+
15+
16+
.. _pre-send-signal:
17+
18+
Pre-send signal
19+
---------------
20+
21+
You can use Anymail's :data:`~anymail.signals.pre_send` signal to examine
22+
or modify messages before they are sent.
23+
For example, you could implement your own email suppression list:
24+
25+
.. code-block:: python
26+
27+
from anymail.exceptions import AnymailCancelSend
28+
from anymail.signals import pre_send
29+
from django.dispatch import receiver
30+
from email.utils import parseaddr
31+
32+
from your_app.models import EmailBlockList
33+
34+
@receiver(pre_send)
35+
def filter_blocked_recipients(sender, message, **kwargs):
36+
# Cancel the entire send if the from_email is blocked:
37+
if not ok_to_send(message.from_email):
38+
raise AnymailCancelSend("Blocked from_email")
39+
# Otherwise filter the recipients before sending:
40+
message.to = [addr for addr in message.to if ok_to_send(addr)]
41+
message.cc = [addr for addr in message.cc if ok_to_send(addr)]
42+
43+
def ok_to_send(addr):
44+
# This assumes you've implemented an EmailBlockList model
45+
# that holds emails you want to reject...
46+
name, email = parseaddr(addr) # just want the <email> part
47+
try:
48+
EmailBlockList.objects.get(email=email)
49+
return False # in the blocklist, so *not* OK to send
50+
except EmailBlockList.DoesNotExist:
51+
return True # *not* in the blocklist, so OK to send
52+
53+
Any changes you make to the message in your pre-send signal receiver
54+
will be reflected in the ESP send API call, as shown for the filtered
55+
"to" and "cc" lists above. Note that this will modify the original
56+
EmailMessage (not a copy)---be sure this won't confuse your sending
57+
code that created the message.
58+
59+
If you want to cancel the message altogether, your pre-send receiver
60+
function can raise an :exc:`~anymail.signals.AnymailCancelSend` exception,
61+
as shown for the "from_email" above. This will silently cancel the send
62+
without raising any other errors.
63+
64+
65+
.. data:: anymail.signals.pre_send
66+
67+
Signal delivered before each EmailMessage is sent.
68+
69+
Your pre_send receiver must be a function with this signature:
70+
71+
.. function:: def my_pre_send_handler(sender, message, **kwargs):
72+
73+
(You can name it anything you want.)
74+
75+
:param class sender:
76+
The Anymail backend class processing the message.
77+
This parameter is required by Django's signal mechanism,
78+
and despite the name has nothing to do with the *email message's* sender.
79+
(You generally won't need to examine this parameter.)
80+
:param ~django.core.mail.EmailMessage message:
81+
The message being sent. If your receiver modifies the message, those
82+
changes will be reflected in the ESP send call.
83+
:param str esp_name:
84+
The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun").
85+
:param \**kwargs:
86+
Required by Django's signal mechanism (to support future extensions).
87+
:raises:
88+
:exc:`anymail.exceptions.AnymailCancelSend` if your receiver wants
89+
to cancel this message without causing errors or interrupting a batch send.
90+
91+
92+
93+
.. _post-send-signal:
94+
95+
Post-send signal
96+
----------------
97+
98+
You can use Anymail's :data:`~anymail.signals.post_send` signal to examine
99+
messages after they are sent. This is useful to centralize handling of
100+
the :ref:`sent status <esp-send-status>` for all messages.
101+
102+
For example, you could implement your own ESP logging dashboard
103+
(perhaps combined with Anymail's :ref:`event-tracking webhooks <event-tracking>`):
104+
105+
.. code-block:: python
106+
107+
from anymail.signals import post_send
108+
from django.dispatch import receiver
109+
110+
from your_app.models import SentMessage
111+
112+
@receiver(post_send)
113+
def log_sent_message(sender, message, status, esp_name, **kwargs):
114+
# This assumes you've implemented a SentMessage model for tracking sends.
115+
# status.recipients is a dict of email: status for each recipient
116+
for email, recipient_status in status.recipients.items():
117+
SentMessage.objects.create(
118+
esp=esp_name,
119+
message_id=recipient_status.message_id, # might be None if send failed
120+
email=email,
121+
subject=message.subject,
122+
status=recipient_status.status, # 'sent' or 'rejected' or ...
123+
)
124+
125+
126+
.. data:: anymail.signals.post_send
127+
128+
Signal delivered after each EmailMessage is sent.
129+
130+
If you register multiple post-send receivers, Anymail will ensure that
131+
all of them are called, even if one raises an error.
132+
133+
Your post_send receiver must be a function with this signature:
134+
135+
.. function:: def my_post_send_handler(sender, message, status, esp_name, **kwargs):
136+
137+
(You can name it anything you want.)
138+
139+
:param class sender:
140+
The Anymail backend class processing the message.
141+
This parameter is required by Django's signal mechanism,
142+
and despite the name has nothing to do with the *email message's* sender.
143+
(You generally won't need to examine this parameter.)
144+
:param ~django.core.mail.EmailMessage message:
145+
The message that was sent. You should not modify this in a post-send receiver.
146+
:param ~anymail.message.AnymailStatus status:
147+
The normalized response from the ESP send call. (Also available as
148+
:attr:`message.anymail_status <anymail.message.AnymailMessage.anymail_status>`.)
149+
:param str esp_name:
150+
The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun").
151+
:param \**kwargs:
152+
Required by Django's signal mechanism (to support future extensions).

tests/test_general_backend.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@ def setUp(self):
2727
# Simple message useful for many tests
2828
self.message = AnymailMessage('Subject', 'Text Body', '[email protected]', ['[email protected]'])
2929

30+
@staticmethod
31+
def get_send_count():
32+
"""Returns number of times "send api" has been called this test"""
33+
return len(recorded_send_params)
34+
3035
@staticmethod
3136
def get_send_params():
37+
"""Returns the params for the most recent "send api" call"""
3238
return recorded_send_params[-1]
3339

3440

tests/test_send_signals.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from django.dispatch import receiver
2+
3+
from anymail.backends.test import TestBackend
4+
from anymail.exceptions import AnymailCancelSend, AnymailRecipientsRefused
5+
from anymail.message import AnymailRecipientStatus
6+
from anymail.signals import pre_send, post_send
7+
8+
from .test_general_backend import TestBackendTestCase
9+
10+
11+
class TestPreSendSignal(TestBackendTestCase):
12+
"""Test Anymail's pre_send signal"""
13+
14+
def test_pre_send(self):
15+
"""Pre-send receivers invoked for each message, before sending"""
16+
@receiver(pre_send, weak=False)
17+
def handle_pre_send(sender, message, esp_name, **kwargs):
18+
self.assertEqual(self.get_send_count(), 0) # not sent yet
19+
self.assertEqual(sender, TestBackend)
20+
self.assertEqual(message, self.message)
21+
self.assertEqual(esp_name, "Test") # the TestBackend's ESP is named "Test"
22+
self.receiver_called = True
23+
self.addCleanup(pre_send.disconnect, receiver=handle_pre_send)
24+
25+
self.receiver_called = False
26+
self.message.send()
27+
self.assertTrue(self.receiver_called)
28+
self.assertEqual(self.get_send_count(), 1) # sent now
29+
30+
def test_modify_message_in_pre_send(self):
31+
"""Pre-send receivers can modify message"""
32+
@receiver(pre_send, weak=False)
33+
def handle_pre_send(sender, message, esp_name, **kwargs):
34+
message.to = [email for email in message.to if not email.startswith('bad')]
35+
message.body += "\nIf you have received this message in error, ignore it"
36+
self.addCleanup(pre_send.disconnect, receiver=handle_pre_send)
37+
38+
self.message.to = ['[email protected]', '[email protected]']
39+
self.message.send()
40+
params = self.get_send_params()
41+
self.assertEqual([email.email for email in params['to']], # params['to'] is ParsedEmail list
42+
43+
self.assertRegex(params['text_body'],
44+
r"If you have received this message in error, ignore it$")
45+
46+
def test_cancel_in_pre_send(self):
47+
"""Pre-send receiver can cancel the send"""
48+
@receiver(pre_send, weak=False)
49+
def cancel_pre_send(sender, message, esp_name, **kwargs):
50+
raise AnymailCancelSend("whoa there")
51+
self.addCleanup(pre_send.disconnect, receiver=cancel_pre_send)
52+
53+
self.message.send()
54+
self.assertEqual(self.get_send_count(), 0) # send API not called
55+
56+
57+
class TestPostSendSignal(TestBackendTestCase):
58+
"""Test Anymail's post_send signal"""
59+
60+
def test_post_send(self):
61+
"""Post-send receiver called for each message, after sending"""
62+
@receiver(post_send, weak=False)
63+
def handle_post_send(sender, message, status, esp_name, **kwargs):
64+
self.assertEqual(self.get_send_count(), 1) # already sent
65+
self.assertEqual(sender, TestBackend)
66+
self.assertEqual(message, self.message)
67+
self.assertEqual(status.status, {'sent'})
68+
self.assertEqual(status.message_id, 1) # TestBackend default message_id
69+
self.assertEqual(status.recipients['[email protected]'].status, 'sent')
70+
self.assertEqual(status.recipients['[email protected]'].message_id, 1)
71+
self.assertEqual(esp_name, "Test") # the TestBackend's ESP is named "Test"
72+
self.receiver_called = True
73+
self.addCleanup(post_send.disconnect, receiver=handle_post_send)
74+
75+
self.receiver_called = False
76+
self.message.send()
77+
self.assertTrue(self.receiver_called)
78+
79+
def test_post_send_exception(self):
80+
"""All post-send receivers called, even if one throws"""
81+
@receiver(post_send, weak=False)
82+
def handler_1(sender, message, status, esp_name, **kwargs):
83+
raise ValueError("oops")
84+
self.addCleanup(post_send.disconnect, receiver=handler_1)
85+
86+
@receiver(post_send, weak=False)
87+
def handler_2(sender, message, status, esp_name, **kwargs):
88+
self.handler_2_called = True
89+
self.addCleanup(post_send.disconnect, receiver=handler_2)
90+
91+
self.handler_2_called = False
92+
with self.assertRaises(ValueError):
93+
self.message.send()
94+
self.assertTrue(self.handler_2_called)
95+
96+
def test_rejected_recipients(self):
97+
"""Post-send receiver even if AnymailRecipientsRefused is raised"""
98+
@receiver(post_send, weak=False)
99+
def handle_post_send(sender, message, status, esp_name, **kwargs):
100+
self.receiver_called = True
101+
self.addCleanup(post_send.disconnect, receiver=handle_post_send)
102+
103+
self.message.test_response = {
104+
'recipient_status': {
105+
'[email protected]': AnymailRecipientStatus(message_id=None, status='rejected')
106+
}
107+
}
108+
109+
self.receiver_called = False
110+
with self.assertRaises(AnymailRecipientsRefused):
111+
self.message.send()
112+
self.assertTrue(self.receiver_called)

0 commit comments

Comments
 (0)