mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ Sending email
|
|||||||
anymail_additions
|
anymail_additions
|
||||||
templates
|
templates
|
||||||
tracking
|
tracking
|
||||||
|
signals
|
||||||
exceptions
|
exceptions
|
||||||
|
|||||||
152
docs/sending/signals.rst
Normal file
152
docs/sending/signals.rst
Normal 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).
|
||||||
@@ -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
112
tests/test_send_signals.py
Normal 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)
|
||||||
Reference in New Issue
Block a user