From df881fdb759a9ddd00d1a5ab28a3de76ac7e249e Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 29 Apr 2016 14:34:34 -0700 Subject: [PATCH] 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 --- anymail/backends/base.py | 11 +++++---- anymail/backends/mailgun.py | 6 +++-- anymail/backends/mandrill.py | 6 +++-- anymail/backends/postmark.py | 6 +++-- anymail/backends/sendgrid.py | 23 ++++++++++-------- anymail/exceptions.py | 11 +++++++-- anymail/utils.py | 41 +++++++++++++++++++++++---------- docs/installation.rst | 8 +++++++ docs/tips/multiple_backends.rst | 15 ++++++++---- tests/test_general_backend.py | 35 ++++++++++++++++++++++++++++ 10 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 tests/test_general_backend.py diff --git a/anymail/backends/base.py b/anymail/backends/base.py index bcb7219..1802fb0 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -17,12 +17,15 @@ class AnymailBaseBackend(BaseEmailBackend): def __init__(self, *args, **kwargs): super(AnymailBaseBackend, self).__init__(*args, **kwargs) - self.ignore_unsupported_features = get_anymail_setting("IGNORE_UNSUPPORTED_FEATURES", False) - self.ignore_recipient_status = get_anymail_setting("IGNORE_RECIPIENT_STATUS", False) + self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features', + kwargs=kwargs, default=False) + self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status', + kwargs=kwargs, default=False) # Merge SEND_DEFAULTS and _SEND_DEFAULTS settings - send_defaults = get_anymail_setting("SEND_DEFAULTS", {}) - esp_send_defaults = get_anymail_setting("%s_SEND_DEFAULTS" % self.esp_name.upper(), None) + send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs + esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name, + kwargs=kwargs, default=None) if esp_send_defaults is not None: send_defaults = send_defaults.copy() send_defaults.update(esp_send_defaults) diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index 1ad518b..201a10b 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -14,8 +14,10 @@ class MailgunBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" - self.api_key = get_anymail_setting('MAILGUN_API_KEY', allow_bare=True) - api_url = get_anymail_setting("MAILGUN_API_URL", "https://api.mailgun.net/v3") + esp_name = self.esp_name + 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("/"): api_url += "/" super(MailgunBackend, self).__init__(api_url, **kwargs) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 710bdc9..240a6df 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -14,8 +14,10 @@ class MandrillBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" - self.api_key = get_anymail_setting('MANDRILL_API_KEY', allow_bare=True) - api_url = get_anymail_setting("MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") + esp_name = self.esp_name + 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("/"): api_url += "/" super(MandrillBackend, self).__init__(api_url, **kwargs) diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index daa1ceb..ef2dc56 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -14,8 +14,10 @@ class PostmarkBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" - self.server_token = get_anymail_setting('POSTMARK_SERVER_TOKEN', allow_bare=True) - api_url = get_anymail_setting("POSTMARK_API_URL", "https://api.postmarkapp.com/") + esp_name = self.esp_name + 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("/"): api_url += "/" super(PostmarkBackend, self).__init__(api_url, **kwargs) diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index c4fbdec..2920a90 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -1,10 +1,9 @@ from email.utils import unquote -from django.core.exceptions import ImproperlyConfigured from django.core.mail import make_msgid from requests.structures import CaseInsensitiveDict -from ..exceptions import AnymailRequestsAPIError +from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, timestamp @@ -19,19 +18,25 @@ class SendGridBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" # 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) - self.username = get_anymail_setting('SENDGRID_USERNAME', default=None, allow_bare=True) - self.password = get_anymail_setting('SENDGRID_PASSWORD', default=None, allow_bare=True) - if self.api_key is None and self.username is None and self.password is None: - raise ImproperlyConfigured( + esp_name = self.esp_name + self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, + default=None, 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) + 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 " "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) - 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("/"): api_url += "/" super(SendGridBackend, self).__init__(api_url, **kwargs) diff --git a/anymail/exceptions.py b/anymail/exceptions.py index c3ff7b6..b378295 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -125,8 +125,15 @@ class AnymailSerializationError(AnymailError, TypeError): super(AnymailSerializationError, self).__init__(message, *args, **kwargs) -# This deliberately doesn't inherit from AnymailError -class AnymailImproperlyInstalled(ImproperlyConfigured, ImportError): +class AnymailConfigurationError(ImproperlyConfigured): + """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=""): message = "The %s package is required to use this backend, but isn't installed.\n" \ "(Be sure to use `pip install django-anymail[%s]` " \ diff --git a/anymail/utils.py b/anymail/utils.py index c4b4a16..9a2b4d6 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -7,10 +7,10 @@ from time import mktime import six 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.utils.timezone import utc +from .exceptions import AnymailConfigurationError UNSET = object() # Used as non-None default value @@ -158,25 +158,42 @@ def get_content_disposition(mimeobj): return str(value).partition(';')[0].strip().lower() -def get_anymail_setting(setting, default=UNSET, allow_bare=False): - """Returns a Django Anymail setting. +def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False): + """Returns an Anymail option from kwargs or Django settings. Returns first of: - - settings.ANYMAIL[setting] - - settings.ANYMAIL_ - - settings. (only if allow_bare) - - default if provided; else raises ImproperlyConfigured + - kwargs[name] -- e.g., kwargs['api_key'] -- and name key will be popped from kwargs + - settings.ANYMAIL['_'] -- e.g., settings.ANYMAIL['MAILGUN_API_KEY'] + - settings.ANYMAIL__ -- e.g., settings.ANYMAIL_MAILGUN_API_KEY + - settings._ (only if allow_bare) -- e.g., settings.MAILGUN_API_KEY + - default if provided; else raises AnymailConfigurationError - ANYMAIL = { "MAILGUN_SEND_DEFAULTS" : { ... }, ... } - ANYMAIL_MAILGUN_SEND_DEFAULTS = { ... } - - If allow_bare, allows settings. without the ANYMAIL_ prefix: + If allow_bare, allows settings._ without the ANYMAIL_ prefix: ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... } ANYMAIL_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 + try: return settings.ANYMAIL[setting] except (AttributeError, KeyError): @@ -193,7 +210,7 @@ def get_anymail_setting(setting, default=UNSET, allow_bare=False): if allow_bare: message += " or %s" % setting message += " in your Django settings" - raise ImproperlyConfigured(message) + raise AnymailConfigurationError(message) else: return default diff --git a/docs/installation.rst b/docs/installation.rst index 20c5497..7bde6b2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -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 +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). See the :ref:`supported ESPs ` section for details. Here are the other settings Anymail supports: diff --git a/docs/tips/multiple_backends.rst b/docs/tips/multiple_backends.rst index b54e945..9d24133 100644 --- a/docs/tips/multiple_backends.rst +++ b/docs/tips/multiple_backends.rst @@ -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: .. 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 @@ -28,9 +28,17 @@ but send admin emails directly through an SMTP server: # You can even use multiple Anymail backends in the same app: 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) + # 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 :func:`~django.core.mail.send_mail` and :func:`~django.core.mail.send_mass_mail` helpers, 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 on Django's admin error logging.) - -.. _django.utils.log.AdminEmailHandler: - https://docs.djangoproject.com/en/stable/topics/logging/#django.utils.log.AdminEmailHandler diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py new file mode 100644 index 0000000..f69451b --- /dev/null +++ b/tests/test_general_backend.py @@ -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')