from datetime import datetime, timezone from email.mime.text import MIMEText from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.core.mail import get_connection, send_mail from django.test import SimpleTestCase from django.test.utils import override_settings from django.utils.functional import Promise from django.utils.translation import gettext_lazy from anymail.backends.test import EmailBackend as TestBackend, TestPayload from anymail.exceptions import ( AnymailConfigurationError, AnymailError, AnymailInvalidAddress, AnymailUnsupportedFeature, ) from anymail.message import AnymailMessage from anymail.utils import get_anymail_setting from .utils import AnymailTestMixin class SettingsTestBackend(TestBackend): """(useful only for these tests)""" def __init__(self, *args, **kwargs): esp_name = self.esp_name self.sample_setting = get_anymail_setting( "sample_setting", esp_name=esp_name, kwargs=kwargs, allow_bare=True ) self.username = get_anymail_setting( "username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True ) self.password = get_anymail_setting( "password", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True ) super().__init__(*args, **kwargs) @override_settings(EMAIL_BACKEND="anymail.backends.test.EmailBackend") class TestBackendTestCase(AnymailTestMixin, SimpleTestCase): """Base TestCase using Anymail's Test EmailBackend""" def setUp(self): super().setUp() # Simple message useful for many tests 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""" try: return len(mail.outbox) except AttributeError: # mail.outbox not initialized by either Anymail test # or Django locmem backend return 0 @staticmethod def get_send_params(): """Returns the params for the most recent "send api" call""" try: return mail.outbox[-1].anymail_test_params except IndexError: raise IndexError( "No messages have been sent through the Anymail test backend" ) except AttributeError: raise AttributeError( "The last message sent was not processed" " through the Anymail test backend" ) @override_settings(EMAIL_BACKEND="tests.test_general_backend.SettingsTestBackend") class BackendSettingsTests(TestBackendTestCase): """Test settings initializations for Anymail EmailBackends""" @override_settings(ANYMAIL={"TEST_SAMPLE_SETTING": "setting_from_anymail_settings"}) def test_anymail_setting(self): """ESP settings usually come from ANYMAIL settings dict""" backend = get_connection() self.assertEqual(backend.sample_setting, "setting_from_anymail_settings") @override_settings(TEST_SAMPLE_SETTING="setting_from_bare_settings") def test_bare_setting(self): """ESP settings are also usually allowed at root of settings file""" backend = get_connection() self.assertEqual(backend.sample_setting, "setting_from_bare_settings") @override_settings(ANYMAIL={"TEST_SAMPLE_SETTING": "setting_from_settings"}) def test_connection_kwargs_overrides_settings(self): """Can override settings file in get_connection""" backend = get_connection() self.assertEqual(backend.sample_setting, "setting_from_settings") backend = get_connection(sample_setting="setting_from_kwargs") self.assertEqual(backend.sample_setting, "setting_from_kwargs") def test_missing_setting(self): """Settings without defaults must be provided""" with self.assertRaises(AnymailConfigurationError) as cm: get_connection() self.assertIsInstance(cm.exception, ImproperlyConfigured) # Django consistency errmsg = str(cm.exception) self.assertRegex(errmsg, r"\bTEST_SAMPLE_SETTING\b") self.assertRegex(errmsg, r"\bANYMAIL_TEST_SAMPLE_SETTING\b") @override_settings( ANYMAIL={ "TEST_USERNAME": "username_from_settings", "TEST_PASSWORD": "password_from_settings", "TEST_SAMPLE_SETTING": "required", } ) def test_username_password_kwargs_overrides(self): """Overrides for 'username' and 'password' should work like other overrides""" # These are special-cased because of default args in Django core mail functions. backend = get_connection() self.assertEqual(backend.username, "username_from_settings") self.assertEqual(backend.password, "password_from_settings") backend = get_connection( username="username_from_kwargs", password="password_from_kwargs" ) self.assertEqual(backend.username, "username_from_kwargs") self.assertEqual(backend.password, "password_from_kwargs") class UnsupportedFeatureTests(TestBackendTestCase): """Tests mail features not supported by backend are handled properly""" def test_unsupported_feature(self): """Unsupported features raise AnymailUnsupportedFeature""" # Test EmailBackend doesn't support non-HTML alternative parts self.message.attach_alternative(b"FAKE_MP3_DATA", "audio/mpeg") with self.assertRaises(AnymailUnsupportedFeature): self.message.send() @override_settings(ANYMAIL={"IGNORE_UNSUPPORTED_FEATURES": True}) def test_ignore_unsupported_features(self): """Setting prevents exception""" self.message.attach_alternative(b"FAKE_MP3_DATA", "audio/mpeg") self.message.send() # should not raise exception class SendDefaultsTests(TestBackendTestCase): """Tests backend support for global SEND_DEFAULTS and _SEND_DEFAULTS""" @override_settings( ANYMAIL={ "SEND_DEFAULTS": { # This isn't an exhaustive list of Anymail message attrs; # just one of each type "metadata": {"global": "globalvalue"}, "send_at": datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc), "tags": ["globaltag"], "template_id": "my-template", "track_clicks": True, "esp_extra": {"globalextra": "globalsetting"}, } } ) def test_send_defaults(self): """Test that (non-esp-specific) send defaults are applied""" self.message.send() params = self.get_send_params() # All these values came from ANYMAIL_SEND_DEFAULTS: self.assertEqual(params["metadata"], {"global": "globalvalue"}) self.assertEqual( params["send_at"], datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc) ) self.assertEqual(params["tags"], ["globaltag"]) self.assertEqual(params["template_id"], "my-template") self.assertEqual(params["track_clicks"], True) # Test EmailBackend merges esp_extra into params: self.assertEqual(params["globalextra"], "globalsetting") @override_settings( ANYMAIL={ # SEND_DEFAULTS for the Test EmailBackend, because # "TEST" is the name of the Test EmailBackend's ESP "TEST_SEND_DEFAULTS": { "metadata": {"global": "espvalue"}, "tags": ["esptag"], "track_opens": False, "esp_extra": {"globalextra": "espsetting"}, } } ) def test_esp_send_defaults(self): """Test that esp-specific send defaults are applied""" self.message.send() params = self.get_send_params() self.assertEqual(params["metadata"], {"global": "espvalue"}) self.assertEqual(params["tags"], ["esptag"]) self.assertEqual(params["track_opens"], False) # Test EmailBackend merges esp_extra into params: self.assertEqual(params["globalextra"], "espsetting") @override_settings( ANYMAIL={ "SEND_DEFAULTS": { "metadata": {"global": "globalvalue", "other": "othervalue"}, "tags": ["globaltag"], "track_clicks": True, "track_opens": False, "esp_extra": { "globalextra": "globalsetting", "deepextra": {"deep1": "globaldeep1", "deep2": "globaldeep2"}, }, } } ) def test_send_defaults_combine_with_message(self): """Individual message settings are *merged into* the global send defaults""" self.message.metadata = {"message": "messagevalue", "other": "override"} self.message.tags = ["messagetag"] self.message.track_clicks = False self.message.esp_extra = { "messageextra": "messagesetting", "deepextra": {"deep2": "messagedeep2", "deep3": "messagedeep3"}, } self.message.send() params = self.get_send_params() self.assertEqual( params["metadata"], { # metadata merged "global": "globalvalue", # global default preserved "message": "messagevalue", # message setting added "other": "override", # message setting overrides global default }, ) # tags concatenated: self.assertEqual(params["tags"], ["globaltag", "messagetag"]) self.assertEqual(params["track_clicks"], False) # message overrides self.assertEqual(params["track_opens"], False) # (no message setting) # esp_extra is deep merged: self.assertEqual(params["globalextra"], "globalsetting") self.assertEqual(params["messageextra"], "messagesetting") self.assertEqual( params["deepextra"], {"deep1": "globaldeep1", "deep2": "messagedeep2", "deep3": "messagedeep3"}, ) # Send another message to make sure original SEND_DEFAULTS unchanged send_mail("subject", "body", "from@example.com", ["to@example.com"]) params = self.get_send_params() self.assertEqual( params["metadata"], {"global": "globalvalue", "other": "othervalue"} ) self.assertEqual(params["tags"], ["globaltag"]) self.assertEqual(params["track_clicks"], True) self.assertEqual(params["track_opens"], False) self.assertEqual(params["globalextra"], "globalsetting") self.assertEqual( params["deepextra"], {"deep1": "globaldeep1", "deep2": "globaldeep2"} ) @override_settings( ANYMAIL={ "SEND_DEFAULTS": { # This isn't an exhaustive list of Anymail message attrs; # just one of each type "metadata": {"global": "globalvalue"}, "tags": ["globaltag"], "template_id": "global-template", "esp_extra": {"globalextra": "globalsetting"}, }, # "TEST" is the name of the Test EmailBackend's ESP "TEST_SEND_DEFAULTS": { "merge_global_data": {"esp": "espmerge"}, "metadata": {"esp": "espvalue"}, "tags": ["esptag"], "esp_extra": {"espextra": "espsetting"}, }, } ) def test_esp_send_defaults_override_globals(self): """ESP-specific send defaults override *individual* global defaults""" self.message.send() params = self.get_send_params() # esp-defaults only: self.assertEqual(params["merge_global_data"], {"esp": "espmerge"}) self.assertEqual(params["metadata"], {"esp": "espvalue"}) self.assertEqual(params["tags"], ["esptag"]) # global-defaults only: self.assertEqual(params["template_id"], "global-template") self.assertEqual(params["espextra"], "espsetting") # entire esp_extra is overriden by esp-send-defaults: self.assertNotIn("globalextra", params) class LazyStringsTest(TestBackendTestCase): """ Tests gettext_lazy strings forced real before passing to ESP transport. Docs notwithstanding, Django lazy strings *don't* work anywhere regular strings would. In particular, they aren't instances of unicode/str. There are some cases (e.g., urllib.urlencode, requests' _encode_params) where this can cause encoding errors or just very wrong results. Since Anymail sits on the border between Django app code and non-Django ESP code (e.g., requests), it's responsible for converting lazy text to actual strings. """ def assertNotLazy(self, s, msg=None): self.assertNotIsInstance(s, Promise, msg=msg or "String %r is lazy" % str(s)) def test_lazy_from(self): # This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL # is meant to be localized self.message.from_email = gettext_lazy('"Global Sales" ') self.message.send() params = self.get_send_params() self.assertNotLazy(params["from"].address) def test_lazy_subject(self): self.message.subject = gettext_lazy("subject") self.message.send() params = self.get_send_params() self.assertNotLazy(params["subject"]) def test_lazy_body(self): self.message.body = gettext_lazy("text body") self.message.attach_alternative(gettext_lazy("html body"), "text/html") self.message.send() params = self.get_send_params() self.assertNotLazy(params["text_body"]) self.assertNotLazy(params["html_body"]) def test_lazy_headers(self): self.message.extra_headers["X-Test"] = gettext_lazy("Test Header") self.message.send() params = self.get_send_params() self.assertNotLazy(params["extra_headers"]["X-Test"]) def test_lazy_attachments(self): self.message.attach( gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv" ) self.message.attach(MIMEText(gettext_lazy("contact info"))) self.message.send() params = self.get_send_params() self.assertNotLazy(params["attachments"][0].name) self.assertNotLazy(params["attachments"][0].content) self.assertNotLazy(params["attachments"][1].content) def test_lazy_tags(self): self.message.tags = [gettext_lazy("Shipping"), gettext_lazy("Sales")] self.message.send() params = self.get_send_params() self.assertNotLazy(params["tags"][0]) self.assertNotLazy(params["tags"][1]) def test_lazy_metadata(self): self.message.metadata = {"order_type": gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() self.assertNotLazy(params["metadata"]["order_type"]) def test_lazy_merge_data(self): self.message.merge_data = { "to@example.com": {"duration": gettext_lazy("One Month")} } self.message.merge_global_data = {"order_type": gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() self.assertNotLazy(params["merge_data"]["to@example.com"]["duration"]) self.assertNotLazy(params["merge_global_data"]["order_type"]) class CatchCommonErrorsTests(TestBackendTestCase): """Anymail should catch and provide useful errors for common mistakes""" def test_explains_reply_to_must_be_list(self): """reply_to must be a list (or other iterable), not a single string""" # Django's EmailMessage.__init__ catches this and warns, but isn't # involved if you assign attributes later. Anymail should catch that case. # (This also applies to to, cc, and bcc, but Django stumbles over those cases # in EmailMessage.recipients (called from EmailMessage.send) before # Anymail gets a chance to complain.) self.message.reply_to = "single-reply-to@example.com" with self.assertRaisesMessage( TypeError, '"reply_to" attribute must be a list or other iterable' ): self.message.send() def test_explains_reply_to_must_be_list_lazy(self): """Same as previous tests, with lazy strings""" # Lazy strings can fool string/iterable detection self.message.reply_to = gettext_lazy("single-reply-to@example.com") with self.assertRaisesMessage( TypeError, '"reply_to" attribute must be a list or other iterable' ): self.message.send() def test_identifies_source_of_parsing_errors(self): """Errors parsing email addresses should say which field had the problem""" # Note: General email address parsing tests are in # test_utils.ParseAddressListTests. This just checks the error includes the # field name when parsing for sending a message. self.message.from_email = "" with self.assertRaisesMessage( AnymailInvalidAddress, "Invalid email address '' parsed from '' in `from_email`.", ): self.message.send() self.message.from_email = "from@example.com" # parse_address_list self.message.to = ["ok@example.com", "oops"] with self.assertRaisesMessage( AnymailInvalidAddress, "Invalid email address 'oops' parsed from 'ok@example.com, oops' in `to`.", ): self.message.send() self.message.to = ["test@example.com"] # parse_single_address self.message.envelope_sender = "one@example.com, two@example.com" with self.assertRaisesMessage( AnymailInvalidAddress, "Only one email address is allowed; found 2" " in 'one@example.com, two@example.com' in `envelope_sender`.", ): self.message.send() delattr(self.message, "envelope_sender") # process_extra_headers self.message.extra_headers["From"] = "Mail, Inc. " with self.assertRaisesMessage( AnymailInvalidAddress, "Invalid email address 'Mail' parsed from" " 'Mail, Inc. ' in `extra_headers['From']`." " (Maybe missing quotes around a display-name?)", ): self.message.send() def test_error_minimizes_pii_leakage(self): """ AnymailError messages should generally avoid including email addresses where not relevant to the error. (This is not a guarantee that exceptions will never include email addresses or other PII. The ESP's own error--which *is* deliberately included in the message--will often include the email address, and Anymail makes no attempt to filter that.) """ # Cause an error (not related to the specific email addresses involved): self.message.attach_alternative("...", "audio/mpeg4") with self.assertRaises(AnymailError) as cm: self.message.send() error = cm.exception self.assertNotIn("from@example.com", str(error)) self.assertNotIn("to@example.com", str(error)) def flatten_emails(emails): return [str(email) for email in emails] class SpecialHeaderTests(TestBackendTestCase): """Anymail should handle special extra_headers the same way Django does""" def test_reply_to(self): """ Django allows message.reply_to and message.extra_headers['Reply-To'], and the latter takes precedence """ self.message.reply_to = ["attr@example.com"] self.message.extra_headers = {"X-Extra": "extra"} self.message.send() params = self.get_send_params() self.assertEqual(flatten_emails(params["reply_to"]), ["attr@example.com"]) self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) self.message.reply_to = None self.message.extra_headers = { "Reply-To": "header@example.com", "X-Extra": "extra", } self.message.send() params = self.get_send_params() self.assertEqual(flatten_emails(params["reply_to"]), ["header@example.com"]) # Reply-To no longer there: self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) # If both are supplied, the header wins (to match Django EmailMessage.message() # behavior). Also, header names are case-insensitive. self.message.reply_to = ["attr@example.com"] self.message.extra_headers = { "REPLY-to": "header@example.com", "X-Extra": "extra", } self.message.send() params = self.get_send_params() self.assertEqual(flatten_emails(params["reply_to"]), ["header@example.com"]) # Reply-To no longer there self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) def test_envelope_sender(self): """ Django treats message.from_email as envelope-sender if message.extra_headers['From'] is set """ # Using Anymail's envelope_sender extension self.message.from_email = "Header From " self.message.envelope_sender = ( "Envelope From " # Anymail extension ) self.message.send() params = self.get_send_params() self.assertEqual(params["from"].address, "Header From ") self.assertEqual(params["envelope_sender"], "envelope@bounces.example.com") # Using Django's undocumented message.extra_headers['From'] extension # (see https://code.djangoproject.com/ticket/9214) self.message.from_email = "Envelope From " self.message.extra_headers = {"From": "Header From "} self.message.send() params = self.get_send_params() self.assertEqual(params["from"].address, "Header From ") self.assertEqual(params["envelope_sender"], "envelope@bounces.example.com") # From was removed from extra-headers: self.assertNotIn("From", params.get("extra_headers", {})) def test_spoofed_to_header(self): """ Django treats message.to as envelope-recipient if message.extra_headers['To'] is set """ # No current ESP supports this (and it's unlikely they would) self.message.to = ["actual-recipient@example.com"] self.message.extra_headers = { "To": "Apparent Recipient " } with self.assertRaisesMessage( AnymailUnsupportedFeature, "spoofing `To` header" ): self.message.send() class AlternativePartsTests(TestBackendTestCase): """ Anymail should handle alternative parts consistently with Django's SMTP backend """ def test_default_usage(self): """Body defaults to text/plain, use alternative for html""" self.message.body = "plain body" self.message.attach_alternative("html body", "text/html") self.message.send() params = self.get_send_params() self.assertEqual(params["text_body"], "plain body") self.assertEqual(params["html_body"], "html body") self.assertNotIn("alternatives", params) def test_content_subtype_html(self): """Change body to text/html, use alternative for plain""" self.message.content_subtype = "html" self.message.body = "html body" self.message.attach_alternative("plain body", "text/plain") self.message.send() params = self.get_send_params() self.assertEqual(params["text_body"], "plain body") self.assertEqual(params["html_body"], "html body") self.assertNotIn("alternatives", params) def test_attach_plain_and_html(self): """Use alternatives for both bodies""" message = AnymailMessage( subject="Subject", from_email="from@example.com", to=["to@example.com"] ) message.attach_alternative("plain body", "text/plain") message.attach_alternative("html body", "text/html") message.send() params = self.get_send_params() self.assertEqual(params["text_body"], "plain body") self.assertEqual(params["html_body"], "html body") self.assertNotIn("alternatives", params) def test_additional_plain_part(self): """Two plaintext bodies""" # In theory this is supported (e.g., for different languages or charsets), # though MUAs are unlikely to display anything after the first. self.message.body = "plain body" self.message.attach_alternative("second plain body", "text/plain") self.message.send() params = self.get_send_params() self.assertEqual(params["text_body"], "plain body") self.assertEqual(params["alternatives"], [("second plain body", "text/plain")]) def test_exotic_content_subtype(self): """Change body to text/calendar, use alternatives for plain and html""" # This is unlikely to work with most ESPs, but we can try to communicate the # intent... (You probably want an attachment rather than an alternative part.) self.message.content_subtype = "calendar" self.message.body = "BEGIN:VCALENDAR..." self.message.attach_alternative("plain body", "text/plain") self.message.attach_alternative("html body", "text/html") self.message.send() params = self.get_send_params() self.assertEqual(params["text_body"], "plain body") self.assertEqual(params["html_body"], "html body") self.assertEqual( params["alternatives"], [("BEGIN:VCALENDAR...", "text/calendar")] ) class BatchSendDetectionTestCase(TestBackendTestCase): """Tests shared code to consistently determine whether to use batch send""" def test_default_is_not_batch(self): self.message.send() params = self.get_send_params() self.assertFalse(params["is_batch_send"]) def test_merge_data_implies_batch(self): self.message.merge_data = {} # *anything* (even empty dict) implies batch self.message.send() params = self.get_send_params() self.assertTrue(params["is_batch_send"]) def test_merge_metadata_implies_batch(self): self.message.merge_metadata = {} # *anything* (even empty dict) implies batch self.message.send() params = self.get_send_params() self.assertTrue(params["is_batch_send"]) def test_merge_global_data_does_not_imply_batch(self): self.message.merge_global_data = {} self.message.send() params = self.get_send_params() self.assertFalse(params["is_batch_send"]) def test_cannot_call_is_batch_during_init(self): # It's tempting to try to warn about unsupported batch features in setters, # but because of the way payload attrs are processed, it won't work... class ImproperlyImplementedPayload(TestPayload): def set_cc(self, emails): if self.is_batch(): # this won't work here! self.unsupported_feature("cc with batch send") super().set_cc(emails) connection = mail.get_connection( "anymail.backends.test.EmailBackend", payload_class=ImproperlyImplementedPayload, ) with self.assertRaisesMessage( AssertionError, "Cannot call is_batch before all attributes processed" ): connection.send_messages([self.message])