diff --git a/anymail/backends/base.py b/anymail/backends/base.py new file mode 100644 index 0000000..455f051 --- /dev/null +++ b/anymail/backends/base.py @@ -0,0 +1,248 @@ +import json +import requests + +from django.core.mail.backends.base import BaseEmailBackend + +from .._version import __version__ +from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailImproperlyConfigured + + +class AnymailBaseBackend(BaseEmailBackend): + """ + Base Anymail email backend + """ + + def open(self): + """ + Open and persist a connection to the ESP's API, and whether + a new connection was created. + + Callers must ensure they later call close, if (and only if) open + returns True. + """ + # Subclasses should use an instance property to maintain a cached + # connection, and return True iff they initialize that instance + # property in _this_ open call. (If the cached connection already + # exists, just do nothing and return False.) + # + # Subclasses should swallow operational errors if self.fail_silently + # (e.g., network errors), but otherwise can raise any errors. + # + # (Returning a bool to indicate whether connection was created is + # borrowed from django.core.email.backends.SMTPBackend) + return False + + def close(self): + """ + Close the cached connection created by open. + + You must only call close if your code called open and it returned True. + """ + # Subclasses should tear down the cached connection and clear + # the instance property. + # + # Subclasses should swallow operational errors if self.fail_silently + # (e.g., network errors), but otherwise can raise any errors. + pass + + def send_messages(self, email_messages): + """ + Sends one or more EmailMessage objects and returns the number of email + messages sent. + """ + # This API is specified by Django's core BaseEmailBackend + # (so you can't change it to, e.g., return detailed status). + # Subclasses shouldn't need to override. + + num_sent = 0 + if not email_messages: + return num_sent + + created_session = self.open() + + try: + for message in email_messages: + try: + sent = self._send(message) + except AnymailError: + if self.fail_silently: + sent = False + else: + raise + if sent: + num_sent += 1 + finally: + if created_session: + self.close() + + return num_sent + + def _send(self, message): + """Sends the EmailMessage message, and returns True if the message was sent. + + This should only be called by the base send_messages loop. + + Implementations must raise exceptions derived from AnymailError for + anticipated failures that should be suppressed in fail_silently mode. + """ + message.anymail_status = None + esp_response_attr = "%s_response" % self.esp_name.lower() # e.g., message.mandrill_response + setattr(message, esp_response_attr, None) # until we have a response + if not message.recipients(): + return False + + payload = self.build_message_payload(message) + # FUTURE: if pre-send-signal OK... + response = self.post_to_esp(payload, message) + + parsed_response = self.deserialize_response(response, payload, message) + setattr(message, esp_response_attr, parsed_response) + message.anymail_status = self.validate_response(parsed_response, response, payload, message) + # FUTURE: post-send signal + + return True + + def build_message_payload(self, message): + """Return a payload with all message-specific options for ESP send call. + + message is an EmailMessage, possibly with additional Anymail-specific attrs + + Can raise AnymailUnsupportedFeature for unsupported options in message. + """ + raise NotImplementedError("%s.%s must implement build_message_payload" % + (self.__class__.__module__, self.__class__.__name__)) + + def post_to_esp(self, payload, message): + """Post payload to ESP send API endpoint, and return the raw response. + + payload is the result of build_message_payload + message is the original EmailMessage + return should be a raw response + + Can raise AnymailAPIError (or derived exception) for problems posting to the ESP + """ + raise NotImplementedError("%s.%s must implement build_message_payload" % + (self.__class__.__module__, self.__class__.__name__)) + + def deserialize_response(self, response, payload, message): + """Deserialize a raw ESP response + + Can raise AnymailAPIError (or derived exception) if response is unparsable + """ + raise NotImplementedError("%s.%s must implement build_message_payload" % + (self.__class__.__module__, self.__class__.__name__)) + + def validate_response(self, parsed_response, response, payload, message): + """Validate parsed_response, raising exceptions for any problems, and return normalized status. + + Extend this to provide your own validation checks. + Validation exceptions should inherit from anymail.exceptions.AnymailError + for proper fail_silently behavior. + + If *all* recipients are refused or invalid, should raise AnymailRecipientsRefused + + Returns one of "sent", "queued", "refused", "error" or "multi" + """ + raise NotImplementedError("%s.%s must implement validate_response" % + (self.__class__.__module__, self.__class__.__name__)) + + @property + def esp_name(self): + """ + Read-only name of the ESP for this backend. + + (E.g., MailgunBackend will return "Mailgun") + """ + return self.__class__.__name__.replace("Backend", "") + + +class AnymailRequestsBackend(AnymailBaseBackend): + """ + Base Anymail email backend for ESPs that use an HTTP API via requests + """ + + def __init__(self, api_url, **kwargs): + """Init options from Django settings""" + self.api_url = api_url + super(AnymailRequestsBackend, self).__init__(**kwargs) + self.session = None + + def open(self): + if self.session: + return False # already exists + + try: + self.session = requests.Session() + except requests.RequestException: + if not self.fail_silently: + raise + else: + self.session.headers["User-Agent"] = "Anymail/%s %s" % ( + __version__, self.session.headers.get("User-Agent", "")) + return True + + def close(self): + if self.session is None: + return + try: + self.session.close() + except requests.RequestException: + if not self.fail_silently: + raise + finally: + self.session = None + + def _send(self, message): + if self.session is None: + class_name = self.__class__.__name__ + raise RuntimeError( + "Session has not been opened in {class_name}._send. " + "(This is either an implementation error in {class_name}, " + "or you are incorrectly calling _send directly.)".format(class_name=class_name)) + return super(AnymailRequestsBackend, self)._send(message) + + def get_api_url(self, payload, message): + """Return the correct ESP url for sending payload + + Override this to substitute your own logic for determining API endpoint. + """ + return self.api_url + + def serialize_payload(self, payload, message): + """Return payload serialized to post data. + + Should raise AnymailSerializationError if payload is not serializable + """ + try: + return json.dumps(payload) + except TypeError as err: + # Add some context to the "not JSON serializable" message + raise AnymailSerializationError(orig_err=err, email_message=message, payload=payload) + + def post_to_esp(self, payload, message): + """Post payload to ESP send API endpoint, and return the raw response. + + payload is the result of build_message_payload + message is the original EmailMessage + return should be a requests.Response + + Can raise AnymailRequestsAPIError for HTTP errors in the post + """ + api_url = self.get_api_url(payload, message) + post_data = self.serialize_payload(payload, message) + + response = self.session.post(api_url, data=post_data) + if response.status_code != 200: + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + return response + + def deserialize_response(self, response, payload, message): + """Return parsed ESP API response + + Can raise AnymailRequestsAPIError if response is unparsable + """ + try: + return response.json() + except ValueError: + raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, + email_message=message, payload=payload, response=response) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index e537406..9cf1b69 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -1,6 +1,4 @@ -import json import mimetypes -import requests from base64 import b64encode from datetime import date, datetime from email.mime.base import MIMEBase @@ -11,32 +9,32 @@ except ImportError: from urllib.parse import urljoin # python 3 from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE -from .._version import __version__ -from ..exceptions import (AnymailError, AnymailRequestsAPIError, AnymailRecipientsRefused, - AnymailSerializationError, AnymailUnsupportedFeature) +from ..exceptions import (AnymailImproperlyConfigured, + AnymailRequestsAPIError, AnymailRecipientsRefused, + AnymailUnsupportedFeature) + +from .base import AnymailRequestsBackend -class MandrillBackend(BaseEmailBackend): +class MandrillBackend(AnymailRequestsBackend): """ Mandrill API Email Backend """ def __init__(self, **kwargs): """Init options from Django settings""" - super(MandrillBackend, self).__init__(**kwargs) + api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") + if not api_url.endswith("/"): + api_url += "/" + + super(MandrillBackend, self).__init__(api_url, **kwargs) try: self.api_key = settings.MANDRILL_API_KEY except AttributeError: - raise ImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Djrill") - - self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") - if not self.api_url.endswith("/"): - self.api_url += "/" + raise AnymailImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Anymail Mandrill backend") self.global_settings = {} try: @@ -44,7 +42,7 @@ class MandrillBackend(BaseEmailBackend): except AttributeError: pass # no MANDRILL_SETTINGS setting except (TypeError, ValueError): # e.g., not enumerable - raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping") + raise AnymailImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping") try: self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT @@ -54,100 +52,7 @@ class MandrillBackend(BaseEmailBackend): self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) self.session = None - def open(self): - """ - Ensure we have a requests Session to connect to the Mandrill API. - Returns True if a new session was created (and the caller must close it). - """ - if self.session: - return False # already exists - - try: - self.session = requests.Session() - except requests.RequestException: - if not self.fail_silently: - raise - else: - self.session.headers["User-Agent"] = "Djrill/%s %s" % ( - __version__, self.session.headers.get("User-Agent", "")) - return True - - def close(self): - """ - Close the Mandrill API Session unconditionally. - - (You should call this only if you called open and it returned True; - else someone else created the session and will clean it up themselves.) - """ - if self.session is None: - return - try: - self.session.close() - except requests.RequestException: - if not self.fail_silently: - raise - finally: - self.session = None - - def send_messages(self, email_messages): - """ - Sends one or more EmailMessage objects and returns the number of email - messages sent. - """ - if not email_messages: - return 0 - - created_session = self.open() - if not self.session: - return 0 # exception in self.open with fail_silently - - num_sent = 0 - try: - for message in email_messages: - sent = self._send(message) - if sent: - num_sent += 1 - finally: - if created_session: - self.close() - - return num_sent - - def _send(self, message): - message.mandrill_response = None # until we have a response - if not message.recipients(): - return False - - try: - payload = self.get_base_payload() - self.build_send_payload(payload, message) - response = self.post_to_mandrill(payload, message) - - # add the response from mandrill to the EmailMessage so callers can inspect it - message.mandrill_response = self.parse_response(response, payload, message) - self.validate_response(message.mandrill_response, response, payload, message) - - except AnymailError: - # every *expected* error is derived from AnymailError; - # we deliberately don't silence unexpected errors - if not self.fail_silently: - raise - return False - - return True - - def get_base_payload(self): - """Return non-message-dependent payload for Mandrill send call - - (The return value will be modified for the send, so must be a copy - of any shared state.) - """ - payload = { - "key": self.api_key, - } - return payload - - def build_send_payload(self, payload, message): + def build_message_payload(self, message): """Modify payload to add all message-specific options for Mandrill send call. payload is a dict that will become the Mandrill send data @@ -160,12 +65,17 @@ class MandrillBackend(BaseEmailBackend): if getattr(message, 'alternatives', None): self._add_alternatives(message, msg_dict) self._add_attachments(message, msg_dict) - payload.setdefault('message', {}).update(msg_dict) + + payload = { + "key": self.api_key, + "message": msg_dict, + } if hasattr(message, 'template_name'): payload['template_name'] = message.template_name payload['template_content'] = \ self._expand_merge_vars(getattr(message, 'template_content', {})) self._add_mandrill_toplevel_options(message, payload) + return payload def get_api_url(self, payload, message): """Return the correct Mandrill API url for sending payload @@ -178,67 +88,28 @@ class MandrillBackend(BaseEmailBackend): api_method = "messages/send.json" return urljoin(self.api_url, api_method) - def serialize_payload(self, payload, message): - """Return payload serialized to a json str. - - Override this to substitute your own JSON serializer (e.g., to handle dates) - """ - return json.dumps(payload) - - def post_to_mandrill(self, payload, message): - """Post payload to correct Mandrill send API endpoint, and return the response. - - payload is a dict to use as Mandrill send data - message is the original EmailMessage - return should be a requests.Response - - Can raise AnymailSerializationError if payload is not serializable - Can raise AnymailRequestsAPIError for HTTP errors in the post - """ - api_url = self.get_api_url(payload, message) - try: - json_payload = self.serialize_payload(payload, message) - except TypeError as err: - # Add some context to the "not JSON serializable" message - raise AnymailSerializationError( - orig_err=err, email_message=message, payload=payload) - - response = self.session.post(api_url, data=json_payload) - if response.status_code != 200: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) - return response - - def parse_response(self, response, payload, message): - """Return parsed json from Mandrill API response - - Can raise AnymailRequestsAPIError if response is not valid JSON - """ - try: - return response.json() - except ValueError: - raise AnymailRequestsAPIError("Invalid JSON in Mandrill API response", - email_message=message, payload=payload, response=response) - def validate_response(self, parsed_response, response, payload, message): """Validate parsed_response, raising exceptions for any problems. - - Extend this to provide your own validation checks. - Validation exceptions should inherit from anymail.exceptions.DjrillException - for proper fail_silently behavior. - - The base version here checks for invalid or refused recipients. """ - if self.ignore_recipient_status: - return try: - recipient_status = [item["status"] for item in parsed_response] + unique_statuses = set([item["status"] for item in parsed_response]) except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mandrill API response format", email_message=message, payload=payload, response=response) - # Error if *all* recipients are invalid or refused - # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) - if all([status in ('invalid', 'rejected') for status in recipient_status]): - raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response) + + if unique_statuses == {"sent"}: + return "sent" + elif unique_statuses == {"queued"}: + return "queued" + elif unique_statuses.issubset({"invalid", "rejected"}): + if self.ignore_recipient_status: + return "refused" + else: + # Error if *all* recipients are invalid or refused + # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) + raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response) + else: + return "multi" # # Payload construction diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 70e2300..7a41cb5 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ImproperlyConfigured import json from requests import HTTPError @@ -67,6 +68,10 @@ class AnymailError(Exception): return description +class AnymailImproperlyConfigured(AnymailError, ImproperlyConfigured): + """Exception for configuration problems""" + + class AnymailAPIError(AnymailError): """Exception for unsuccessful response from ESP's API.""" diff --git a/anymail/tests/test_mandrill_send.py b/anymail/tests/test_mandrill_send.py index 283bd11..5707c77 100644 --- a/anymail/tests/test_mandrill_send.py +++ b/anymail/tests/test_mandrill_send.py @@ -525,7 +525,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) sent = msg.send() self.assertEqual(sent, 1) + # noinspection PyUnresolvedReferences self.assertEqual(msg.mandrill_response, response) + # noinspection PyUnresolvedReferences + self.assertEqual(msg.anymail_status, "sent") def test_send_failed_mandrill_response(self): """ If the send fails, mandrill_response should be set to None """ @@ -533,7 +536,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) sent = msg.send(fail_silently=True) self.assertEqual(sent, 0) + # noinspection PyUnresolvedReferences self.assertIsNone(msg.mandrill_response) + # noinspection PyUnresolvedReferences + self.assertIsNone(msg.anymail_status) def test_send_unparsable_mandrill_response(self): """If the send succeeds, but a non-JSON API response, should raise an API exception""" @@ -541,7 +547,10 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) with self.assertRaises(AnymailAPIError): msg.send() + # noinspection PyUnresolvedReferences self.assertIsNone(msg.mandrill_response) + # noinspection PyUnresolvedReferences + self.assertIsNone(msg.anymail_status) def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data"""