diff --git a/entity_emailer/interface.py b/entity_emailer/interface.py index b0a071e..00df989 100644 --- a/entity_emailer/interface.py +++ b/entity_emailer/interface.py @@ -1,3 +1,4 @@ +import json import sys import traceback @@ -18,8 +19,8 @@ class EntityEmailerInterface(object): An api interface to do things within entity emailer """ - @staticmethod - def send_unsent_scheduled_emails(): + @classmethod + def send_unsent_scheduled_emails(cls): """ Send out any scheduled emails that are unsent """ @@ -43,7 +44,7 @@ def send_unsent_scheduled_emails(): context_loader.load_contexts_and_renderers([e.event for e in to_send], [email_medium]) # Keep track of what emails we will be sending - emails = [] + emails_to_send = [] # Loop over each email and generate the recipients, and message # and handle any exceptions that may occur @@ -80,22 +81,22 @@ def send_unsent_scheduled_emails(): ) # Add the email to the list of emails that need to be sent - emails.append(message) - except Exception as e: + emails_to_send.append({ + 'message': message, + 'model': email, + }) + except Exception: # Save the exception on the model - email.exception = traceback.format_exc() - email.save(update_fields=['exception']) - - # Fire the email exception event - email_exception.send( - sender=Email, - email=email, - exception=e - ) + cls.save_email_exception(email, traceback.format_exc()) # Send all the emails that were generated properly - connection = mail.get_connection() - connection.send_messages(emails) + with mail.get_connection() as connection: + for email in emails_to_send: + try: + # Send mail + connection.send_message(email.get('message')) + except Exception as e: + cls.save_email_exception(email.get('model'), e) # Update the emails as sent to_send.update(sent=current_time) @@ -150,3 +151,22 @@ def bulk_convert_events_to_emails(): # Bulk create the emails Email.objects.create_emails(email_params_list) + + @classmethod + def save_email_exception(cls, email, e): + # Save the error to the email model + exception_message = str(e) + + # Duck typing exception for sendgrid api backend rather than place hard dependency + if hasattr(e, 'to_dict'): + exception_message += ': {}'.format(json.dumps(e.to_dict())) + + email.exception = exception_message + email.save(update_fields=['exception']) + + # Fire the email exception event + email_exception.send( + sender=Email, + email=email, + exception=e + ) diff --git a/entity_emailer/tests/interface_tests.py b/entity_emailer/tests/interface_tests.py index 79a4ff2..4429337 100644 --- a/entity_emailer/tests/interface_tests.py +++ b/entity_emailer/tests/interface_tests.py @@ -1,5 +1,7 @@ from datetime import datetime +import json +from django.conf import settings from django.core import mail from django.core.mail import EmailMultiAlternatives from django.core.management import call_command @@ -365,8 +367,11 @@ def test_sends_all_scheduled_emails(self, render_mock, address_mock): address_mock.return_value = ['test1@example.com', 'test2@example.com'] g_email(context={}, scheduled=datetime.min) g_email(context={}, scheduled=datetime.min) - EntityEmailerInterface.send_unsent_scheduled_emails() - self.assertEqual(len(mail.outbox), 2) + + with patch(settings.EMAIL_BACKEND) as mock_connection: + EntityEmailerInterface.send_unsent_scheduled_emails() + + self.assertEqual(2, mock_connection.return_value.__enter__.return_value.send_message.call_count) @patch('entity_emailer.interface.pre_send') @patch('entity_emailer.interface.get_subscribed_email_addresses') @@ -382,21 +387,23 @@ def test_send_signals(self, render_mock, address_mock, mock_pre_send): email = g_email(context={ 'test': 'test' }, scheduled=datetime.min) - EntityEmailerInterface.send_unsent_scheduled_emails() - # Assert that we sent the email - self.assertEqual(len(mail.outbox), 1) + with patch(settings.EMAIL_BACKEND) as mock_connection: + EntityEmailerInterface.send_unsent_scheduled_emails() - # Assert that we called the pre send signal with the proper values - name, args, kwargs = mock_pre_send.send.mock_calls[0] - self.assertEqual(kwargs['sender'], email.event.source.name) - self.assertEqual(kwargs['email'], email) - self.assertEqual(kwargs['event'], email.event) - self.assertEqual(kwargs['context'], { - 'test': 'test', - 'entity_emailer_id': str(email.view_uid) - }) - self.assertIsInstance(kwargs['message'], EmailMultiAlternatives) + # Assert that we sent the email + self.assertEqual(1, mock_connection.return_value.__enter__.return_value.send_message.call_count) + + # Assert that we called the pre send signal with the proper values + name, args, kwargs = mock_pre_send.send.mock_calls[0] + self.assertEqual(kwargs['sender'], email.event.source.name) + self.assertEqual(kwargs['email'], email) + self.assertEqual(kwargs['event'], email.event) + self.assertEqual(kwargs['context'], { + 'test': 'test', + 'entity_emailer_id': str(email.view_uid) + }) + self.assertIsInstance(kwargs['message'], EmailMultiAlternatives) @patch('entity_emailer.interface.get_subscribed_email_addresses') @patch.object(Event, 'render', spec_set=True) @@ -405,8 +412,12 @@ def test_sends_email_with_specified_from_address(self, render_mock, address_mock address_mock.return_value = ['test1@example.com', 'test2@example.com'] from_address = 'test@example.com' g_email(context={}, from_address=from_address, scheduled=datetime.min) - EntityEmailerInterface.send_unsent_scheduled_emails() - self.assertEqual(mail.outbox[0].from_email, from_address) + + with patch(settings.EMAIL_BACKEND) as mock_connection: + EntityEmailerInterface.send_unsent_scheduled_emails() + + args = mock_connection.return_value.__enter__.return_value.send_message.call_args + self.assertEqual(args[0][0].from_email, from_address) @patch('entity_emailer.interface.get_subscribed_email_addresses') @patch.object(Event, 'render', spec_set=True) @@ -458,15 +469,57 @@ def test_exceptions(self, render_mock, address_mock, mock_email_exception): }, scheduled=datetime.min) # Send the emails - EntityEmailerInterface.send_unsent_scheduled_emails() + with patch(settings.EMAIL_BACKEND) as mock_connection: + EntityEmailerInterface.send_unsent_scheduled_emails() + + # Assert that both emails were marked as sent + self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2) - # Assert that both emails were marked as sent - self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2) + # Assert that only one email is actually sent through backend + self.assertEquals(1, mock_connection.call_count) + # Assert that one email raised an exception + exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False) + self.assertIsNotNone(exception_email) + self.assertTrue('Exception: test' in exception_email.exception) - # Assert that one email raised an exception - exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False) - self.assertIsNotNone(exception_email) - self.assertTrue('Exception: test' in exception_email.exception) + @patch.object(Event, 'render', spec_set=True) + @patch('entity_emailer.interface.get_subscribed_email_addresses') + def test_send_exceptions(self, mock_get_subscribed_addresses, mock_render): + """ + Verifies that when a single email raises an exception from within the backend, the batch is still + updated as sent and the failed email is saved with the exception + """ + # Create test emails to send + g_email(context={}, scheduled=datetime.min) + failed_email = g_email(context={}, scheduled=datetime.min) + mock_get_subscribed_addresses.return_value = ['test1@example.com'] + mock_render.return_value = ('foo', 'bar',) + + # Verify baseline, namely that both emails are not marked as sent and that neither has an exception saved + self.assertEquals(2, Email.objects.filter(sent__isnull=True).count()) + + class TestEmailSendMessageException(Exception): + def to_dict(self): + return {'message': str(self)} + + with patch(settings.EMAIL_BACKEND) as mock_connection: + # Mock side effects for sending emails + mock_connection.return_value.__enter__.return_value.send_message.side_effect = [ + None, + TestEmailSendMessageException('test'), + ] + + EntityEmailerInterface.send_unsent_scheduled_emails() + + # Verify that both emails are marked as sent + self.assertEquals(2, Email.objects.filter(sent__isnull=False).count()) + # Verify that the failed email was saved with the exception + actual_failed_email = Email.objects.get(sent__isnull=False, exception__isnull=False) + self.assertEquals(failed_email.id, actual_failed_email.id) + self.assertEquals( + 'test: {}'.format(json.dumps({'message': 'test'})), + actual_failed_email.exception + ) class CreateEmailObjectTest(TestCase): diff --git a/entity_emailer/version.py b/entity_emailer/version.py index afced14..3f39079 100644 --- a/entity_emailer/version.py +++ b/entity_emailer/version.py @@ -1 +1 @@ -__version__ = '2.0.0' +__version__ = '2.0.1' diff --git a/release_notes.rst b/release_notes.rst index 30d5525..b1a2557 100644 --- a/release_notes.rst +++ b/release_notes.rst @@ -1,6 +1,10 @@ Release Notes ============= +v2.0.1 +------ +* Fix for handling single failures in a batch of outgoing emails + v2.0.0 ------ * Added bulk interface for converting to emails