def deliver(batch_id: str, redirect_address: Optional[str] = None) -> Tuple[int, int]: """Delivers emails for the given batch. Delivers emails to the email address specified in the generated email filename. If a redirect_address is provided, emails are sent to the redirect_address and the original recipient's email address's name (without the domain) is appended to the name (i.e. [email protected]). Args: batch_id: The identifier for the batch redirect_address: (optional) An email address to which all emails will be sent Returns: A tuple with counts of successful deliveries and failures (successes, failures) Raises: Raises errors related to external services like Google Storage but continues attempting to send subsequent emails if it receives an exception while attempting to send. In that case the error is logged. """ if redirect_address: logging.info("Redirecting all emails for batch %s to be sent to %s", batch_id, redirect_address) else: logging.info("Delivering emails for batch %s", batch_id) try: from_email_address = utils.get_env_var('FROM_EMAIL_ADDRESS') from_email_name = utils.get_env_var('FROM_EMAIL_NAME') except KeyError: logging.error("Unable to get a required environment variables `FROM_EMAIL_ADDRESS` or `FROM_EMAIL_NAME`. " "Exiting.") raise files = retrieve_html_files(batch_id) success_count = 0 fail_count = 0 sendgrid = SendGridClientWrapper() for recipient_email_address in files: sent_successfully = sendgrid.send_message(to_email=recipient_email_address, from_email=from_email_address, from_email_name=from_email_name, subject=EMAIL_SUBJECT, html_content=files[recipient_email_address], redirect_address=redirect_address) if sent_successfully: success_count = success_count + 1 else: fail_count = fail_count + 1 logging.info("Sent %s emails. %s emails failed to send", success_count, fail_count) return success_count, fail_count
def setUp(self) -> None: HttpResponse = collections.namedtuple('Response', ['status_code']) self.error_response = HttpResponse(404) self.success_response = HttpResponse(202) self.client_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.SendGridAPIClient') self.secrets_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.secrets').start() self.mail_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.Mail') self.email_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.Email') self.mock_client = self.client_patcher.start().return_value self.mock_mail = self.mail_patcher.start() self.mock_email = self.email_patcher.start() self.secrets_patcher.get_secret.return_value = 'secret' self.wrapper = SendGridClientWrapper()
def setUp(self) -> None: HttpResponse = collections.namedtuple("HttpResponse", ["status_code"]) self.error_response = HttpResponse(404) self.success_response = HttpResponse(202) self.client_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.SendGridAPIClient") self.secrets_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.secrets").start() self.mail_helpers_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.mail_helpers") self.mock_mail_helpers = self.mail_helpers_patcher.start() self.mock_client = self.client_patcher.start().return_value self.secrets_patcher.get_secret.return_value = "secret" self.wrapper = SendGridClientWrapper()
class SendGridClientWrapperTest(TestCase): """Tests for the wrapper class SendGridClientWrapper""" def setUp(self) -> None: HttpResponse = collections.namedtuple("HttpResponse", ["status_code"]) self.error_response = HttpResponse(404) self.success_response = HttpResponse(202) self.client_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.SendGridAPIClient" ) self.secrets_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.secrets" ).start() self.mail_helpers_patcher = patch( "recidiviz.reporting.sendgrid_client_wrapper.mail_helpers" ) self.mock_mail_helpers = self.mail_helpers_patcher.start() self.mock_client = self.client_patcher.start().return_value self.secrets_patcher.get_secret.return_value = "secret" self.wrapper = SendGridClientWrapper() self.attachment_title = "2021-05 Recidiviz Monthly Report - Client Details.txt" def tearDown(self) -> None: self.client_patcher.stop() self.secrets_patcher.stop() self.mail_helpers_patcher.stop() def test_send_message(self) -> None: """Test that send_message sends the return from create_message client's send method and that the Mail helper is called with the right arguments. Test that it returns True on success.""" to_email = "*****@*****.**" from_email = "<Recidiviz> [email protected]" mail_message = "message" subject = "Your monthly Recidiviz Report" html_content = "<html></html>" self.mock_mail_helpers.Email.return_value = from_email self.mock_mail_helpers.Mail.return_value = mail_message self.mock_client.send.return_value = self.success_response with self.assertLogs(level="INFO"): response = self.wrapper.send_message( to_email=to_email, from_email="*****@*****.**", subject=subject, html_content=html_content, attachment_title=self.attachment_title, from_email_name="Recidiviz", ) self.assertTrue(response) self.mock_mail_helpers.CC.assert_not_called() self.mock_mail_helpers.Email.assert_called_with( "*****@*****.**", "Recidiviz" ) self.mock_mail_helpers.Mail.assert_called_with( to_emails=to_email, from_email=from_email, subject=subject, html_content=html_content, ) self.mock_client.send.assert_called_with(mail_message) def test_send_message_with_cc(self) -> None: """Given cc_addresses, test that _create_message creates a list of Cc objects for the Mail message.""" self.mock_client.send.return_value = self.success_response Mail = collections.namedtuple("Mail", []) mock_message = MagicMock(return_value=Mail()) self.mock_mail_helpers.Mail.return_value = mock_message mock_parent = Mock() mock_parent.attach_mock(self.mock_mail_helpers.Cc, "Cc") cc_addresses = ["*****@*****.**", "*****@*****.**"] expected_calls = [call.Cc(email=cc_address) for cc_address in cc_addresses] self.wrapper.send_message( to_email="*****@*****.**", from_email="*****@*****.**", subject="Your monthly Recidiviz Report", html_content="<html></html>", attachment_title=self.attachment_title, from_email_name="Recidiviz", cc_addresses=cc_addresses, ) self.assertTrue(hasattr(mock_message, "cc")) self.assertEqual(len(mock_message.cc), 2) mock_parent.assert_has_calls(expected_calls) def test_send_message_exception(self) -> None: """Test that an error is logged when an exception is raised and it returns False""" self.mock_client.send.side_effect = Exception with self.assertLogs(level="ERROR"): response = self.wrapper.send_message( to_email="*****@*****.**", from_email="*****@*****.**", subject="Your monthly Recidiviz Report", html_content="<html></html>", attachment_title=self.attachment_title, from_email_name="Recidiviz", ) self.assertFalse(response) def test_send_message_with_redirect_address(self) -> None: """Given a redirect_address, test that _create_message is called with the correct to_email and subject line.""" self.mock_mail_helpers.Email.return_value = "<Recidiviz> [email protected]" self.mock_client.send.return_value = self.success_response redirect_address = "*****@*****.**" to_email = "*****@*****.**" subject = "Your monthly Recidiviz Report" with self.assertLogs(level="INFO"): self.wrapper.send_message( to_email=to_email, from_email="*****@*****.**", subject=subject, html_content="<html></html>", attachment_title=self.attachment_title, from_email_name="Recidiviz", redirect_address=redirect_address, ) self.mock_mail_helpers.Mail.assert_called_with( to_emails=redirect_address, from_email="<Recidiviz> [email protected]", subject=f"[{to_email}] {subject}", html_content="<html></html>", ) def test_send_message_with_text_attachment_content(self) -> None: """Given text_attachment_content, test that an attachment is created and attached to the outgoing message.""" file_content = "Fake email attachment content" self.wrapper.send_message( to_email="*****@*****.**", from_email="*****@*****.**", subject="Your monthly Recidiviz Report", html_content="<html></html>", attachment_title=self.attachment_title, from_email_name="Recidiviz", text_attachment_content=file_content, ) self.mock_mail_helpers.Attachment.assert_called_with( file_content=self.mock_mail_helpers.FileContent( "Fake email attachment content" ), file_name=self.mock_mail_helpers.FileName( "2021-05 Recidiviz Monthly Report - Client Details.txt" ), file_type=self.mock_mail_helpers.FileType("text/plain"), disposition=self.mock_mail_helpers.Disposition("attachment"), ) def test_send_message_with_text_attachment_content_none(self) -> None: """Given no text_attachment_content, assert that an attachment is not created.""" self.wrapper.send_message( to_email="*****@*****.**", from_email="*****@*****.**", subject="Your monthly Recidiviz Report", html_content="<html></html>", attachment_title=self.attachment_title, from_email_name="Recidiviz", text_attachment_content=None, ) self.mock_mail_helpers.Attachment.assert_not_called()
def deliver( batch_id: str, state_code: StateCode, report_date: date, redirect_address: Optional[str] = None, cc_addresses: Optional[List[str]] = None, subject_override: Optional[str] = None, email_allowlist: Optional[List[str]] = None, ) -> MultiRequestResult[str, str]: """Delivers emails for the given batch. Delivers emails to the email address specified in the generated email filename. If a redirect_address is provided, emails are sent to the redirect_address and the original recipient's email address's name (without the domain) is appended to the name (i.e. [email protected]). Args: batch_id: The identifier for the batch state_code: (required) A valid state code for which reporting is enabled (ex. "US_ID") report_date: The date of the report (ex. 2021, 5, 31) redirect_address: (optional) An email address to which all emails will be sent cc_addresses: (optional) A list of email addressed to include on the cc line subject_override: (optional) The subject line to override to. email_allowlist: (optional) A subset list of email addresses that are to receive the email. If not provided, all recipients will be sent emails to. Returns: A MultiRequestResult containing successes and failures for the emails that were sent Raises: Raises errors related to external services like Google Storage but continues attempting to send subsequent emails if it receives an exception while attempting to send. In that case the error is logged. """ logging.info("Delivering emails for batch %s", batch_id) if redirect_address: logging.info( "Redirecting all emails for batch %s to be sent to %s", batch_id, redirect_address, ) if cc_addresses: logging.info( "CCing the following addresses: [%s]", ",".join(address for address in cc_addresses), ) try: from_email_address = utils.get_env_var("FROM_EMAIL_ADDRESS") from_email_name = utils.get_env_var("FROM_EMAIL_NAME") except KeyError: logging.error( "Unable to get a required environment variables `FROM_EMAIL_ADDRESS` or `FROM_EMAIL_NAME`. " "Exiting.") raise content_bucket = utils.get_email_content_bucket_name() html_files = load_files_from_storage( content_bucket, utils.get_html_folder(batch_id, state_code)) attachment_files = load_files_from_storage( content_bucket, utils.get_attachments_folder(batch_id, state_code)) if len(html_files.items()) == 0: msg = f"No files found for batch {batch_id} for state {state_code} in the bucket {content_bucket}" logging.error(msg) raise IndexError(msg) if email_allowlist is not None: html_files = { email: content for email, content in html_files.items() if email in email_allowlist } succeeded_email_sends: List[str] = [] failed_email_sends: List[str] = [] sendgrid = SendGridClientWrapper() subject = DEFAULT_EMAIL_SUBJECT if subject_override is None else subject_override report_date_str = report_date.strftime("%Y-%m") attachment_title = ( f"{report_date_str} Recidiviz Monthly Report - Client Details.txt") for recipient_email_address in html_files: sent_successfully = sendgrid.send_message( to_email=recipient_email_address, from_email=from_email_address, from_email_name=from_email_name, subject=subject, html_content=html_files[recipient_email_address], attachment_title=attachment_title, redirect_address=redirect_address, cc_addresses=cc_addresses, text_attachment_content=attachment_files.get( recipient_email_address), ) if sent_successfully: succeeded_email_sends.append(recipient_email_address) else: failed_email_sends.append(recipient_email_address) logging.info( "Sent %s emails. %s emails failed to send", len(succeeded_email_sends), len(failed_email_sends), ) return MultiRequestResult(successes=succeeded_email_sends, failures=failed_email_sends)
def deliver( batch_id: str, redirect_address: Optional[str] = None, cc_addresses: Optional[List[str]] = None, subject_override: Optional[str] = None, ) -> Tuple[int, int]: """Delivers emails for the given batch. Delivers emails to the email address specified in the generated email filename. If a redirect_address is provided, emails are sent to the redirect_address and the original recipient's email address's name (without the domain) is appended to the name (i.e. [email protected]). Args: batch_id: The identifier for the batch redirect_address: (optional) An email address to which all emails will be sent cc_addresses: (optional) A list of email addressed to include on the cc line subject_override: (optional) The subject line to override to. Returns: A tuple with counts of successful deliveries and failures (successes, failures) Raises: Raises errors related to external services like Google Storage but continues attempting to send subsequent emails if it receives an exception while attempting to send. In that case the error is logged. """ logging.info("Delivering emails for batch %s", batch_id) if redirect_address: logging.info( "Redirecting all emails for batch %s to be sent to %s", batch_id, redirect_address, ) if cc_addresses: logging.info( "CCing the following addresses: [%s]", ",".join(address for address in cc_addresses), ) try: from_email_address = utils.get_env_var("FROM_EMAIL_ADDRESS") from_email_name = utils.get_env_var("FROM_EMAIL_NAME") except KeyError: logging.error( "Unable to get a required environment variables `FROM_EMAIL_ADDRESS` or `FROM_EMAIL_NAME`. " "Exiting.") raise content_bucket = utils.get_email_content_bucket_name() html_files = load_files_from_storage(content_bucket, utils.get_html_folder(batch_id)) attachment_files = load_files_from_storage( content_bucket, utils.get_attachments_folder(batch_id)) if len(html_files.items()) == 0: msg = f"No files found for batch {batch_id} in the bucket {content_bucket}" logging.error(msg) raise IndexError(msg) success_count = 0 fail_count = 0 sendgrid = SendGridClientWrapper() subject = DEFAULT_EMAIL_SUBJECT if subject_override is None else subject_override for recipient_email_address in html_files: sent_successfully = sendgrid.send_message( to_email=recipient_email_address, from_email=from_email_address, from_email_name=from_email_name, subject=subject, html_content=html_files[recipient_email_address], redirect_address=redirect_address, cc_addresses=cc_addresses, text_attachment_content=attachment_files.get( recipient_email_address), ) if sent_successfully: success_count = success_count + 1 else: fail_count = fail_count + 1 logging.info("Sent %s emails. %s emails failed to send", success_count, fail_count) return success_count, fail_count
class SendGridClientWrapperTest(TestCase): """Tests for the wrapper class SendGridClientWrapper""" def setUp(self) -> None: HttpResponse = collections.namedtuple('Response', ['status_code']) self.error_response = HttpResponse(404) self.success_response = HttpResponse(202) self.client_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.SendGridAPIClient') self.secrets_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.secrets').start() self.mail_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.Mail') self.email_patcher = patch('recidiviz.reporting.sendgrid_client_wrapper.Email') self.mock_client = self.client_patcher.start().return_value self.mock_mail = self.mail_patcher.start() self.mock_email = self.email_patcher.start() self.secrets_patcher.get_secret.return_value = 'secret' self.wrapper = SendGridClientWrapper() def tearDown(self) -> None: self.client_patcher.stop() self.secrets_patcher.stop() self.mail_patcher.stop() self.email_patcher.stop() def test_send_message(self) -> None: """Test that send_message sends the return from create_message client's send method and that the Mail helper is called with the right arguments. Test that it returns True on success.""" to_email = '*****@*****.**' from_email = '<Recidiviz> [email protected]' mail_message = 'message' subject = 'Your monthly Recidiviz Report' html_content = '<html></html>' self.mock_email.return_value = from_email self.mock_mail.return_value = mail_message self.mock_client.send.return_value = self.success_response args = { 'to_email': to_email, 'from_email': '*****@*****.**', 'subject': subject, 'from_email_name': 'Recidiviz', 'html_content': html_content } with self.assertLogs(level='INFO'): response = self.wrapper.send_message(**args) self.assertTrue(response) self.mock_email.assert_called_with('*****@*****.**', 'Recidiviz') self.mock_mail.assert_called_with(to_emails=to_email, from_email=from_email, subject=subject, html_content=html_content) self.mock_client.send.assert_called_with(mail_message) def test_send_message_exception(self) -> None: """Test that an error is logged when an exception is raised and it returns False""" self.mock_client.send.side_effect = Exception args = { 'to_email': '*****@*****.**', 'from_email': '*****@*****.**', 'subject': 'Your monthly Recidiviz Report', 'from_email_name': 'Recidiviz', 'html_content': '<html></html>' } with self.assertLogs(level='ERROR'): response = self.wrapper.send_message(**args) self.assertFalse(response) def test_send_message_with_redirect_address(self) -> None: """Given a redirect_address, test that _create_message is called with the correct to_email and subject line.""" self.mock_email.return_value = '<Recidiviz> [email protected]' self.mock_client.send.return_value = self.success_response redirect_address = '*****@*****.**' to_email = '*****@*****.**' subject = 'Your monthly Recidiviz Report' args = { 'to_email': to_email, 'from_email': '*****@*****.**', 'subject': subject, 'from_email_name': 'Recidiviz', 'html_content': '<html></html>', 'redirect_address': redirect_address } with self.assertLogs(level='INFO'): self.wrapper.send_message(**args) self.mock_mail.assert_called_with(to_emails=redirect_address, from_email='<Recidiviz> [email protected]', subject=f'[{to_email}] {subject}', html_content='<html></html>')