Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions entity_emailer/interface.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import sys
import traceback

Expand All @@ -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
"""
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
101 changes: 77 additions & 24 deletions entity_emailer/tests/interface_tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion entity_emailer/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2.0.0'
__version__ = '2.0.1'
4 changes: 4 additions & 0 deletions release_notes.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down