mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Django allows setting the reply address with either message.reply_to or message.extra_headers["Reply-To"]. If both are supplied, the extra headers version takes precedence. (See EmailMessage.message().) Several Anymail backends had duplicate logic to handle conflicting properties. Move that logic into the base Payload. (Also prepares for common handling of extra_headers['From'], later.) Related changes: * Use CaseInsensitiveDict for processing extra_headers. This is potentially a breaking change, but any code that was trying to send multiple headers differing only in case was likely already broken. (Email header field names are case-insensitive, per RFC-822.) * Handle CaseInsensitiveDict in RequestsPayload.serialize_json(). (Several backends had duplicate code for handling this, too.) * Fixes SparkPost backend, which had been incorrectly treating message.reply_to and message.extra_headers['Reply-To'] differently.
354 lines
16 KiB
Python
354 lines
16 KiB
Python
from datetime import datetime
|
|
from email.mime.text import MIMEText
|
|
|
|
import six
|
|
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.timezone import utc
|
|
from django.utils.translation import ugettext_lazy
|
|
|
|
from anymail.exceptions import AnymailConfigurationError, AnymailUnsupportedFeature
|
|
from anymail.message import AnymailMessage
|
|
|
|
from .utils import AnymailTestMixin
|
|
|
|
|
|
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend')
|
|
class TestBackendTestCase(SimpleTestCase, AnymailTestMixin):
|
|
"""Base TestCase using Anymail's Test EmailBackend"""
|
|
|
|
def setUp(self):
|
|
super(TestBackendTestCase, self).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:
|
|
return 0 # mail.outbox not initialized by either Anymail test or Django locmem backend
|
|
|
|
@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='anymail.backends.test._EmailBackendWithRequiredSetting')
|
|
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={'SENDGRID_USERNAME': 'username_from_settings',
|
|
'SENDGRID_PASSWORD': 'password_from_settings'})
|
|
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.
|
|
# (Use the SendGrid v2 backend, which has settings named 'username' and 'password'.)
|
|
backend = get_connection('anymail.backends.sendgrid_v2.EmailBackend')
|
|
self.assertEqual(backend.username, 'username_from_settings')
|
|
self.assertEqual(backend.password, 'password_from_settings')
|
|
|
|
backend = get_connection('anymail.backends.sendgrid_v2.EmailBackend',
|
|
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 <ESP>_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=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=utc))
|
|
self.assertEqual(params['tags'], ['globaltag'])
|
|
self.assertEqual(params['template_id'], 'my-template')
|
|
self.assertEqual(params['track_clicks'], True)
|
|
self.assertEqual(params['globalextra'], 'globalsetting') # Test EmailBackend merges esp_extra into params
|
|
|
|
@override_settings(ANYMAIL={
|
|
'TEST_SEND_DEFAULTS': { # "TEST" is the name of the Test EmailBackend's ESP
|
|
'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)
|
|
self.assertEqual(params['globalextra'], 'espsetting') # Test EmailBackend merges esp_extra into params
|
|
|
|
@override_settings(ANYMAIL={
|
|
'SEND_DEFAULTS': {
|
|
'metadata': {'global': 'globalvalue', 'other': 'othervalue'},
|
|
'tags': ['globaltag'],
|
|
'track_clicks': True,
|
|
'track_opens': False,
|
|
'esp_extra': {'globalextra': 'globalsetting'},
|
|
}
|
|
})
|
|
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'}
|
|
|
|
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
|
|
self.assertEqual(params['tags'], ['globaltag', 'messagetag']) # tags concatenated
|
|
self.assertEqual(params['track_clicks'], False) # message overrides
|
|
self.assertEqual(params['track_opens'], False) # (no message setting)
|
|
self.assertEqual(params['globalextra'], 'globalsetting')
|
|
self.assertEqual(params['messageextra'], 'messagesetting')
|
|
|
|
# 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')
|
|
|
|
@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_SEND_DEFAULTS': { # "TEST" is the name of the Test EmailBackend's ESP
|
|
'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()
|
|
self.assertEqual(params['merge_global_data'], {'esp': 'espmerge'}) # esp-defaults only
|
|
self.assertEqual(params['metadata'], {'esp': 'espvalue'})
|
|
self.assertEqual(params['tags'], ['esptag'])
|
|
self.assertEqual(params['template_id'], 'global-template') # global-defaults only
|
|
self.assertEqual(params['espextra'], 'espsetting')
|
|
self.assertNotIn('globalextra', params) # entire esp_extra is overriden by esp-send-defaults
|
|
|
|
|
|
class LazyStringsTest(TestBackendTestCase):
|
|
"""
|
|
Tests ugettext_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" % six.text_type(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 = ugettext_lazy(u'"Global Sales" <sales@example.com>')
|
|
self.message.send()
|
|
params = self.get_send_params()
|
|
self.assertNotLazy(params['from'].address)
|
|
|
|
def test_lazy_subject(self):
|
|
self.message.subject = ugettext_lazy("subject")
|
|
self.message.send()
|
|
params = self.get_send_params()
|
|
self.assertNotLazy(params['subject'])
|
|
|
|
def test_lazy_body(self):
|
|
self.message.body = ugettext_lazy("text body")
|
|
self.message.attach_alternative(ugettext_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'] = ugettext_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(ugettext_lazy("test.csv"), ugettext_lazy("test,csv,data"), "text/csv")
|
|
self.message.attach(MIMEText(ugettext_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 = [ugettext_lazy("Shipping"), ugettext_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': ugettext_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': ugettext_lazy("One Month")}}
|
|
self.message.merge_global_data = {'order_type': ugettext_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 = ugettext_lazy("single-reply-to@example.com")
|
|
with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'):
|
|
self.message.send()
|
|
|
|
|
|
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"])
|
|
self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there
|
|
|
|
# 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"])
|
|
self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there
|