Allow kwargs overrides for (nearly) all settings

* Update utils.get_anymail_setting to support
  kwargs override of django.conf.settings values
* Use the updated version everywhere
* Switch from ImproperlyConfigured to
  AnymailConfigurationError exception
  (anticipates feature_wehooks change)

Closes #12
This commit is contained in:
medmunds
2016-04-29 14:34:34 -07:00
parent 6b415eeaae
commit df881fdb75
10 changed files with 124 additions and 38 deletions

View File

@@ -17,12 +17,15 @@ class AnymailBaseBackend(BaseEmailBackend):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(AnymailBaseBackend, self).__init__(*args, **kwargs) super(AnymailBaseBackend, self).__init__(*args, **kwargs)
self.ignore_unsupported_features = get_anymail_setting("IGNORE_UNSUPPORTED_FEATURES", False) self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
self.ignore_recipient_status = get_anymail_setting("IGNORE_RECIPIENT_STATUS", False) kwargs=kwargs, default=False)
self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status',
kwargs=kwargs, default=False)
# Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings # Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings
send_defaults = get_anymail_setting("SEND_DEFAULTS", {}) send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs
esp_send_defaults = get_anymail_setting("%s_SEND_DEFAULTS" % self.esp_name.upper(), None) esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name,
kwargs=kwargs, default=None)
if esp_send_defaults is not None: if esp_send_defaults is not None:
send_defaults = send_defaults.copy() send_defaults = send_defaults.copy()
send_defaults.update(esp_send_defaults) send_defaults.update(esp_send_defaults)

View File

@@ -14,8 +14,10 @@ class MailgunBackend(AnymailRequestsBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
self.api_key = get_anymail_setting('MAILGUN_API_KEY', allow_bare=True) esp_name = self.esp_name
api_url = get_anymail_setting("MAILGUN_API_URL", "https://api.mailgun.net/v3") self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.mailgun.net/v3")
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(MailgunBackend, self).__init__(api_url, **kwargs) super(MailgunBackend, self).__init__(api_url, **kwargs)

View File

@@ -14,8 +14,10 @@ class MandrillBackend(AnymailRequestsBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
self.api_key = get_anymail_setting('MANDRILL_API_KEY', allow_bare=True) esp_name = self.esp_name
api_url = get_anymail_setting("MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(MandrillBackend, self).__init__(api_url, **kwargs) super(MandrillBackend, self).__init__(api_url, **kwargs)

View File

@@ -14,8 +14,10 @@ class PostmarkBackend(AnymailRequestsBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
self.server_token = get_anymail_setting('POSTMARK_SERVER_TOKEN', allow_bare=True) esp_name = self.esp_name
api_url = get_anymail_setting("POSTMARK_API_URL", "https://api.postmarkapp.com/") self.server_token = get_anymail_setting('server_token', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.postmarkapp.com/")
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(PostmarkBackend, self).__init__(api_url, **kwargs) super(PostmarkBackend, self).__init__(api_url, **kwargs)

View File

@@ -1,10 +1,9 @@
from email.utils import unquote from email.utils import unquote
from django.core.exceptions import ImproperlyConfigured
from django.core.mail import make_msgid from django.core.mail import make_msgid
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError
from ..message import AnymailRecipientStatus from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, timestamp from ..utils import get_anymail_setting, timestamp
@@ -19,19 +18,25 @@ class SendGridBackend(AnymailRequestsBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
# Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
self.api_key = get_anymail_setting('SENDGRID_API_KEY', default=None, allow_bare=True) esp_name = self.esp_name
self.username = get_anymail_setting('SENDGRID_USERNAME', default=None, allow_bare=True) self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs,
self.password = get_anymail_setting('SENDGRID_PASSWORD', default=None, allow_bare=True) default=None, allow_bare=True)
if self.api_key is None and self.username is None and self.password is None: self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs,
raise ImproperlyConfigured( default=None, allow_bare=True)
self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
default=None, allow_bare=True)
if self.api_key is None and (self.username is None or self.password is None):
raise AnymailConfigurationError(
"You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and " "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and "
"SENDGRID_PASSWORD in your Django ANYMAIL settings." "SENDGRID_PASSWORD in your Django ANYMAIL settings."
) )
self.generate_message_id = get_anymail_setting('SENDGRID_GENERATE_MESSAGE_ID', default=True) self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
kwargs=kwargs, default=True)
# This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending) # This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
api_url = get_anymail_setting("SENDGRID_API_URL", "https://api.sendgrid.com/api/") api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.sendgrid.com/api/")
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(SendGridBackend, self).__init__(api_url, **kwargs) super(SendGridBackend, self).__init__(api_url, **kwargs)

View File

@@ -125,8 +125,15 @@ class AnymailSerializationError(AnymailError, TypeError):
super(AnymailSerializationError, self).__init__(message, *args, **kwargs) super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
# This deliberately doesn't inherit from AnymailError class AnymailConfigurationError(ImproperlyConfigured):
class AnymailImproperlyInstalled(ImproperlyConfigured, ImportError): """Exception for Anymail configuration or installation issues"""
# This deliberately doesn't inherit from AnymailError,
# because we don't want it to be swallowed by backend fail_silently
class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
"""Exception for Anymail missing package dependencies"""
def __init__(self, missing_package, backend="<backend>"): def __init__(self, missing_package, backend="<backend>"):
message = "The %s package is required to use this backend, but isn't installed.\n" \ message = "The %s package is required to use this backend, but isn't installed.\n" \
"(Be sure to use `pip install django-anymail[%s]` " \ "(Be sure to use `pip install django-anymail[%s]` " \

View File

@@ -7,10 +7,10 @@ from time import mktime
import six import six
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from django.utils.timezone import utc from django.utils.timezone import utc
from .exceptions import AnymailConfigurationError
UNSET = object() # Used as non-None default value UNSET = object() # Used as non-None default value
@@ -158,25 +158,42 @@ def get_content_disposition(mimeobj):
return str(value).partition(';')[0].strip().lower() return str(value).partition(';')[0].strip().lower()
def get_anymail_setting(setting, default=UNSET, allow_bare=False): def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
"""Returns a Django Anymail setting. """Returns an Anymail option from kwargs or Django settings.
Returns first of: Returns first of:
- settings.ANYMAIL[setting] - kwargs[name] -- e.g., kwargs['api_key'] -- and name key will be popped from kwargs
- settings.ANYMAIL_<setting> - settings.ANYMAIL['<ESP_NAME>_<NAME>'] -- e.g., settings.ANYMAIL['MAILGUN_API_KEY']
- settings.<setting> (only if allow_bare) - settings.ANYMAIL_<ESP_NAME>_<NAME> -- e.g., settings.ANYMAIL_MAILGUN_API_KEY
- default if provided; else raises ImproperlyConfigured - settings.<ESP_NAME>_<NAME> (only if allow_bare) -- e.g., settings.MAILGUN_API_KEY
- default if provided; else raises AnymailConfigurationError
ANYMAIL = { "MAILGUN_SEND_DEFAULTS" : { ... }, ... } If allow_bare, allows settings.<ESP_NAME>_<NAME> without the ANYMAIL_ prefix:
ANYMAIL_MAILGUN_SEND_DEFAULTS = { ... }
If allow_bare, allows settings.<setting> without the ANYMAIL_ prefix:
ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... } ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... }
ANYMAIL_MAILGUN_API_KEY = "xyz" ANYMAIL_MAILGUN_API_KEY = "xyz"
MAILGUN_API_KEY = "xyz" MAILGUN_API_KEY = "xyz"
""" """
try:
value = kwargs.pop(name)
if name in ['username', 'password']:
# Work around a problem in django.core.mail.send_mail, which calls
# get_connection(... username=None, password=None) by default.
# We need to ignore those None defaults (else settings like
# 'SENDGRID_USERNAME' get unintentionally overridden from kwargs).
if value is not None:
return value
else:
return value
except (AttributeError, KeyError):
pass
if esp_name is not None:
setting = "{}_{}".format(esp_name.upper(), name.upper())
else:
setting = name.upper()
anymail_setting = "ANYMAIL_%s" % setting anymail_setting = "ANYMAIL_%s" % setting
try: try:
return settings.ANYMAIL[setting] return settings.ANYMAIL[setting]
except (AttributeError, KeyError): except (AttributeError, KeyError):
@@ -193,7 +210,7 @@ def get_anymail_setting(setting, default=UNSET, allow_bare=False):
if allow_bare: if allow_bare:
message += " or %s" % setting message += " or %s" % setting
message += " in your Django settings" message += " in your Django settings"
raise ImproperlyConfigured(message) raise AnymailConfigurationError(message)
else: else:
return default return default

View File

@@ -122,6 +122,14 @@ if you are using other Django apps that work with the same ESP.)
# nor ANYMAIL_MAILGUN_API_KEY have been set # nor ANYMAIL_MAILGUN_API_KEY have been set
Finally, for complex use cases, you can override most settings on a per-instance
basis by providing keyword args where the instance is initialized (e.g., in a
:func:`~django.core.mail.get_connection` call to create an email backend instance,
or in `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
parameter for a setting, drop "ANYMAIL" and the ESP name, and lowercase the rest:
e.g., you can override ANYMAIL_MAILGUN_API_KEY by passing `api_key="abc"` to
:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example.
There are specific Anymail settings for each ESP (like API keys and urls). There are specific Anymail settings for each ESP (like API keys and urls).
See the :ref:`supported ESPs <supported-esps>` section for details. See the :ref:`supported ESPs <supported-esps>` section for details.
Here are the other settings Anymail supports: Here are the other settings Anymail supports:

View File

@@ -13,7 +13,7 @@ This could be useful, for example, to deliver customer emails with the ESP,
but send admin emails directly through an SMTP server: but send admin emails directly through an SMTP server:
.. code-block:: python .. code-block:: python
:emphasize-lines: 8,10,13,15 :emphasize-lines: 8,10,13,15,19-20,22
from django.core.mail import send_mail, get_connection from django.core.mail import send_mail, get_connection
@@ -28,9 +28,17 @@ but send admin emails directly through an SMTP server:
# You can even use multiple Anymail backends in the same app: # You can even use multiple Anymail backends in the same app:
sendgrid_backend = get_connection('anymail.backends.sendgrid.SendGridBackend') sendgrid_backend = get_connection('anymail.backends.sendgrid.SendGridBackend')
send_mail("Password reset", "Here you go", "user@example.com", ["noreply@example.com"], send_mail("Password reset", "Here you go", "noreply@example.com", ["user@example.com"],
connection=sendgrid_backend) connection=sendgrid_backend)
# You can override settings.py settings with kwargs to get_connection.
# This example supplies credentials to use a SendGrid subuser acccount:
alt_sendgrid_backend = get_connection('anymail.backends.sendgrid.SendGridBackend',
username='marketing_subuser', password='abc123')
send_mail("Here's that info", "you wanted", "marketing@example.com", ["prospect@example.com"],
connection=alt_sendgrid_backend)
You can supply a different connection to Django's You can supply a different connection to Django's
:func:`~django.core.mail.send_mail` and :func:`~django.core.mail.send_mass_mail` helpers, :func:`~django.core.mail.send_mail` and :func:`~django.core.mail.send_mass_mail` helpers,
and in the constructor for an and in the constructor for an
@@ -39,6 +47,3 @@ and in the constructor for an
(See the :class:`django.utils.log.AdminEmailHandler` docs for more information (See the :class:`django.utils.log.AdminEmailHandler` docs for more information
on Django's admin error logging.) on Django's admin error logging.)
.. _django.utils.log.AdminEmailHandler:
https://docs.djangoproject.com/en/stable/topics/logging/#django.utils.log.AdminEmailHandler

View File

@@ -0,0 +1,35 @@
from django.core.mail import get_connection
from django.test import SimpleTestCase
from django.test.utils import override_settings
from .utils import AnymailTestMixin
class BackendSettingsTests(SimpleTestCase, AnymailTestMixin):
"""Test settings initializations for Anymail EmailBackends"""
# We should add a "GenericBackend" or something basic for testing.
# For now, we just access real backends directly.
@override_settings(ANYMAIL={'MAILGUN_API_KEY': 'api_key_from_settings'})
def test_connection_kwargs_overrides_settings(self):
connection = get_connection('anymail.backends.mailgun.MailgunBackend')
self.assertEqual(connection.api_key, 'api_key_from_settings')
connection = get_connection('anymail.backends.mailgun.MailgunBackend',
api_key='api_key_from_kwargs')
self.assertEqual(connection.api_key, 'api_key_from_kwargs')
@override_settings(ANYMAIL={'SENDGRID_USERNAME': 'username_from_settings',
'SENDGRID_PASSWORD': 'password_from_settings'})
def test_username_password_kwargs_overrides(self):
# Additional checks for username and password, which are special-cased
# because of Django core mail function defaults.
connection = get_connection('anymail.backends.sendgrid.SendGridBackend')
self.assertEqual(connection.username, 'username_from_settings')
self.assertEqual(connection.password, 'password_from_settings')
connection = get_connection('anymail.backends.sendgrid.SendGridBackend',
username='username_from_kwargs', password='password_from_kwargs')
self.assertEqual(connection.username, 'username_from_kwargs')
self.assertEqual(connection.password, 'password_from_kwargs')