diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 455f051..bf06fa1 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -1,10 +1,13 @@ import json + import requests +from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend +from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailUnsupportedFeature +from ..utils import Attachment, ParsedEmail, UNSET, combine, last from .._version import __version__ -from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailImproperlyConfigured class AnymailBaseBackend(BaseEmailBackend): @@ -12,6 +15,10 @@ class AnymailBaseBackend(BaseEmailBackend): Base Anymail email backend """ + def __init__(self, *args, **kwargs): + super(AnymailBaseBackend, self).__init__(*args, **kwargs) + self.send_defaults = getattr(settings, "ANYMAIL_SEND_DEFAULTS", {}) + def open(self): """ Open and persist a connection to the ESP's API, and whether @@ -109,9 +116,144 @@ class AnymailBaseBackend(BaseEmailBackend): Can raise AnymailUnsupportedFeature for unsupported options in message. """ - raise NotImplementedError("%s.%s must implement build_message_payload" % + encoding = message.encoding + payload = self.get_base_payload(message) + + # Standard EmailMessage features: + self.set_payload_from_email(payload, ParsedEmail(message.from_email, encoding), message) + for recipient_type in ["to", "cc", "bcc"]: + recipients = getattr(message, recipient_type, []) + if recipients: + emails = [ParsedEmail(address, encoding) for address in recipients] + self.add_payload_recipients(payload, recipient_type, emails, message) + self.set_payload_subject(payload, message.subject, message) + + if hasattr(message, "reply_to"): + emails = [ParsedEmail(address, encoding) for address in message.reply_to] + self.set_payload_reply_to(payload, emails, message) + if hasattr(message, "extra_headers"): + self.add_payload_headers(payload, message.extra_headers, message) + + if message.content_subtype == "html": + self.set_payload_html_body(payload, message.body, message) + else: + self.set_payload_text_body(payload, message.body, message) + + if hasattr(message, "alternatives"): + for (content, mimetype) in message.alternatives: + self.add_payload_alternative(payload, content, mimetype, message) + + str_encoding = encoding or settings.DEFAULT_CHARSET + for attachment in message.attachments: + self.add_payload_attachment(payload, Attachment(attachment, str_encoding), message) + + # Anymail additions: + metadata = self.get_anymail_merged_attr(message, "metadata") # merged: changes semantics from Djrill! + if metadata is not UNSET: + self.set_payload_metadata(payload, metadata, message) + send_at = self.get_anymail_attr(message, "send_at") + if send_at is not UNSET: + self.set_payload_send_at(payload, send_at, message) + tags = self.get_anymail_merged_attr(message, "tags") # merged: changes semantics from Djrill! + if tags is not UNSET: + self.set_payload_tags(payload, tags, message) + track_clicks = self.get_anymail_attr(message, "track_clicks") + if track_clicks is not UNSET: + self.set_payload_track_clicks(payload, track_clicks, message) + track_opens = self.get_anymail_attr(message, "track_opens") + if track_opens is not UNSET: + self.set_payload_track_opens(payload, track_opens, message) + + # ESP-specific fallback: + self.add_payload_esp_options(payload, message) + + return payload + + def get_anymail_attr(self, message, attr): + default_value = self.send_defaults.get(attr, UNSET) + message_value = getattr(message, attr, UNSET) + return last(default_value, message_value) + + def get_anymail_merged_attr(self, message, attr): + default_value = self.send_defaults.get(attr, UNSET) + message_value = getattr(message, attr, UNSET) + return combine(default_value, message_value) + + def unsupported_feature(self, feature): + # future: check settings.ANYMAIL_UNSUPPORTED_FEATURE_ERRORS + raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature)) + + # + # Payload construction + # + + def get_base_payload(self, message): + raise NotImplementedError("%s.%s must implement init_base_payload" % (self.__class__.__module__, self.__class__.__name__)) + def set_payload_from_email(self, payload, email, message): + raise NotImplementedError("%s.%s must implement set_payload_from" % + (self.__class__.__module__, self.__class__.__name__)) + + def add_payload_recipients(self, payload, recipient_type, emails, message): + for email in emails: + self.add_payload_recipient(payload, recipient_type, email, message) + + def add_payload_recipient(self, payload, recipient_type, email, message): + raise NotImplementedError("%s.%s must implement add_payload_recipient" % + (self.__class__.__module__, self.__class__.__name__)) + + def set_payload_subject(self, payload, subject, message): + raise NotImplementedError("%s.%s must implement set_payload_subject" % + (self.__class__.__module__, self.__class__.__name__)) + + def set_payload_reply_to(self, payload, emails, message): + raise NotImplementedError("%s.%s must implement set_payload_reply_to" % + (self.__class__.__module__, self.__class__.__name__)) + + def add_payload_headers(self, payload, headers, message): + raise NotImplementedError("%s.%s must implement add_payload_heeaders" % + (self.__class__.__module__, self.__class__.__name__)) + + def set_payload_text_body(self, payload, body, message): + raise NotImplementedError("%s.%s must implement set_payload_text_body" % + (self.__class__.__module__, self.__class__.__name__)) + + def set_payload_html_body(self, payload, body, message): + raise NotImplementedError("%s.%s must implement set_payload_html_body" % + (self.__class__.__module__, self.__class__.__name__)) + + def add_payload_alternative(self, payload, content, mimetype, message): + raise NotImplementedError("%s.%s must implement add_payload_alternative" % + (self.__class__.__module__, self.__class__.__name__)) + + def add_payload_attachment(self, payload, attachment, message): + raise NotImplementedError("%s.%s must implement add_payload_attachment" % + (self.__class__.__module__, self.__class__.__name__)) + + # Anymail-specific payload construction + def set_payload_metadata(self, payload, metadata, message): + self.unsupported_feature("metadata") + + def set_payload_send_at(self, payload, send_at, message): + self.unsupported_feature("send_at") + + def set_payload_tags(self, payload, tags, message): + self.unsupported_feature("tags") + + def set_payload_track_clicks(self, payload, track_clicks, message): + self.unsupported_feature("track_clicks") + + def set_payload_track_opens(self, payload, track_opens, message): + self.unsupported_feature("track_opens") + + # ESP-specific payload construction + def add_payload_esp_options(self, payload, message): + raise NotImplementedError("%s.%s must implement add_payload_esp_options" % + (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. @@ -121,7 +263,7 @@ class AnymailBaseBackend(BaseEmailBackend): Can raise AnymailAPIError (or derived exception) for problems posting to the ESP """ - raise NotImplementedError("%s.%s must implement build_message_payload" % + raise NotImplementedError("%s.%s must implement post_to_esp" % (self.__class__.__module__, self.__class__.__name__)) def deserialize_response(self, response, payload, message): @@ -129,7 +271,7 @@ class AnymailBaseBackend(BaseEmailBackend): Can raise AnymailAPIError (or derived exception) if response is unparsable """ - raise NotImplementedError("%s.%s must implement build_message_payload" % + raise NotImplementedError("%s.%s must implement deserialize_response" % (self.__class__.__module__, self.__class__.__name__)) def validate_response(self, parsed_response, response, payload, message): diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 9cf1b69..10f0454 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -1,19 +1,13 @@ -import mimetypes -from base64 import b64encode from datetime import date, datetime -from email.mime.base import MIMEBase -from email.utils import parseaddr try: from urlparse import urljoin # python 2 except ImportError: from urllib.parse import urljoin # python 3 from django.conf import settings -from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE -from ..exceptions import (AnymailImproperlyConfigured, - AnymailRequestsAPIError, AnymailRecipientsRefused, - AnymailUnsupportedFeature) +from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError, + AnymailRecipientsRefused, AnymailUnsupportedFeature) from .base import AnymailRequestsBackend @@ -36,47 +30,24 @@ class MandrillBackend(AnymailRequestsBackend): except AttributeError: raise AnymailImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Anymail Mandrill backend") - self.global_settings = {} + # Djrill compat! MANDRILL_SETTINGS try: - self.global_settings.update(settings.MANDRILL_SETTINGS) + self.send_defaults.update(settings.MANDRILL_SETTINGS) except AttributeError: pass # no MANDRILL_SETTINGS setting except (TypeError, ValueError): # e.g., not enumerable raise AnymailImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping") + # Djrill compat! MANDRILL_SUBACCOUNT try: - self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT + self.send_defaults["subaccount"] = settings.MANDRILL_SUBACCOUNT except AttributeError: pass # no MANDRILL_SUBACCOUNT setting + self.global_settings = self.send_defaults self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) self.session = None - 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 - message is an EmailMessage, possibly with additional Mandrill-specific attrs - - Can raise AnymailUnsupportedFeature for unsupported options in message. - """ - msg_dict = self._build_standard_message_dict(message) - self._add_mandrill_options(message, msg_dict) - if getattr(message, 'alternatives', None): - self._add_alternatives(message, msg_dict) - self._add_attachments(message, 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 @@ -115,48 +86,88 @@ class MandrillBackend(AnymailRequestsBackend): # Payload construction # - def _build_standard_message_dict(self, message): - """Create a Mandrill send message struct from a Django EmailMessage. - - Builds the standard dict that Django's send_mail and send_mass_mail - use by default. Standard text email messages sent through Django will - still work through Mandrill. - - Raises AnymailUnsupportedFeature for any standard EmailMessage - features that cannot be accurately communicated to Mandrill. - """ - sender = sanitize_address(message.from_email, message.encoding) - from_name, from_email = parseaddr(sender) - - to_list = self._make_mandrill_to_list(message, message.to, "to") - to_list += self._make_mandrill_to_list(message, message.cc, "cc") - to_list += self._make_mandrill_to_list(message, message.bcc, "bcc") - - content = "html" if message.content_subtype == "html" else "text" - msg_dict = { - content: message.body, - "to": to_list + def get_base_payload(self, message): + return { + "key": self.api_key, + "message": {}, } - if not getattr(message, 'use_template_from', False): - msg_dict["from_email"] = from_email - if from_name: - msg_dict["from_name"] = from_name + def set_payload_from_email(self, payload, email, message): + if not getattr(message, "use_template_from", False): # Djrill compat! + payload["message"]["from_email"] = email.email + if email.name: + payload["message"]["from_name"] = email.name - if not getattr(message, 'use_template_subject', False): - msg_dict["subject"] = message.subject + def add_payload_recipient(self, payload, recipient_type, email, message): + assert recipient_type in ["to", "cc", "bcc"] + to_list = payload["message"].setdefault("to", []) + to_list.append({"email": email.email, "name": email.name, "type": recipient_type}) - if hasattr(message, 'reply_to'): - reply_to = [sanitize_address(addr, message.encoding) for addr in message.reply_to] - msg_dict["headers"] = {'Reply-To': ', '.join(reply_to)} - # Note: An explicit Reply-To header will override the reply_to attr below - # (matching Django's own behavior) + def set_payload_subject(self, payload, subject, message): + if not getattr(message, "use_template_subject", False): # Djrill compat! + payload["message"]["subject"] = subject - if message.extra_headers: - msg_dict["headers"] = msg_dict.get("headers", {}) - msg_dict["headers"].update(message.extra_headers) + def set_payload_reply_to(self, payload, emails, message): + reply_to = ", ".join([email.address for email in emails]) + payload["message"].setdefault("headers", {})["Reply-To"] = reply_to - return msg_dict + def add_payload_headers(self, payload, headers, message): + payload["message"].setdefault("headers", {}).update(headers) + + def set_payload_text_body(self, payload, body, message): + payload["message"]["text"] = body + + def set_payload_html_body(self, payload, body, message): + payload["message"]["html"] = body + + def add_payload_alternative(self, payload, content, mimetype, message): + if mimetype != 'text/html': + raise AnymailUnsupportedFeature( + "Invalid alternative mimetype '%s'. " + "Mandrill only accepts plain text and html emails." + % mimetype, + email_message=message) + + if "html" in payload["message"]: + raise AnymailUnsupportedFeature( + "Too many alternatives attached to the message. " + "Mandrill only accepts plain text and html emails.", + email_message=message) + + payload["message"]["html"] = content + + def add_payload_attachment(self, payload, attachment, message): + key = "images" if attachment.inline else "attachments" + payload["message"].setdefault(key, []).append({ + "type": attachment.mimetype, + "name": attachment.name or "", + "content": attachment.b64content + }) + + def set_payload_metadata(self, payload, metadata, message): + payload["message"]["metadata"] = metadata + + def set_payload_send_at(self, payload, send_at, message): + payload["send_at"] = self.encode_date_for_mandrill(send_at) + + def set_payload_tags(self, payload, tags, message): + payload["message"]["tags"] = tags + + def set_payload_track_clicks(self, payload, track_clicks, message): + payload["message"]["track_clicks"] = track_clicks + + def set_payload_track_opens(self, payload, track_opens, message): + payload["message"]["track_opens"] = track_opens + + def add_payload_esp_options(self, payload, message): + self._add_mandrill_options(message, payload["message"]) + 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) + + # unported def _add_mandrill_toplevel_options(self, message, api_params): """Extend api_params to include Mandrill global-send options set on message""" @@ -170,35 +181,19 @@ class MandrillBackend(AnymailRequestsBackend): if hasattr(message, attr): api_params[attr] = getattr(message, attr) - # Mandrill attributes that require conversion: - if hasattr(message, 'send_at'): - api_params['send_at'] = self.encode_date_for_mandrill(message.send_at) - # setting send_at in global_settings wouldn't make much sense - - def _make_mandrill_to_list(self, message, recipients, recipient_type="to"): - """Create a Mandrill 'to' field from a list of emails. - - Parses "Real Name " format emails. - Sanitizes all email addresses. - """ - parsed_rcpts = [parseaddr(sanitize_address(addr, message.encoding)) - for addr in recipients] - return [{"email": to_email, "name": to_name, "type": recipient_type} - for (to_name, to_email) in parsed_rcpts] - def _add_mandrill_options(self, message, msg_dict): """Extend msg_dict to include Mandrill per-message options set on message""" # Mandrill attributes that can be copied directly: mandrill_attrs = [ 'from_name', # overrides display name parsed from from_email above 'important', - 'track_opens', 'track_clicks', 'auto_text', 'auto_html', + 'auto_text', 'auto_html', 'inline_css', 'url_strip_qs', 'tracking_domain', 'signing_domain', 'return_path_domain', 'merge_language', - 'tags', 'preserve_recipients', 'view_content_link', 'subaccount', + 'preserve_recipients', 'view_content_link', 'subaccount', 'google_analytics_domains', 'google_analytics_campaign', - 'metadata'] + ] for attr in mandrill_attrs: if attr in self.global_settings: @@ -243,98 +238,6 @@ class MandrillBackend(AnymailRequestsBackend): return [{'name': name, 'content': vardict[name]} for name in sorted(vardict.keys())] - def _add_alternatives(self, message, msg_dict): - """ - There can be only one! ... alternative attachment, and it must be text/html. - - Since mandrill does not accept image attachments or anything other - than HTML, the assumption is the only thing you are attaching is - the HTML output for your email. - """ - if len(message.alternatives) > 1: - raise AnymailUnsupportedFeature( - "Too many alternatives attached to the message. " - "Mandrill only accepts plain text and html emails.", - email_message=message) - - (content, mimetype) = message.alternatives[0] - if mimetype != 'text/html': - raise AnymailUnsupportedFeature( - "Invalid alternative mimetype '%s'. " - "Mandrill only accepts plain text and html emails." - % mimetype, - email_message=message) - - msg_dict['html'] = content - - def _add_attachments(self, message, msg_dict): - """Extend msg_dict to include any attachments in message""" - if message.attachments: - str_encoding = message.encoding or settings.DEFAULT_CHARSET - mandrill_attachments = [] - mandrill_embedded_images = [] - for attachment in message.attachments: - att_dict, is_embedded = self._make_mandrill_attachment(attachment, str_encoding) - if is_embedded: - mandrill_embedded_images.append(att_dict) - else: - mandrill_attachments.append(att_dict) - if len(mandrill_attachments) > 0: - msg_dict['attachments'] = mandrill_attachments - if len(mandrill_embedded_images) > 0: - msg_dict['images'] = mandrill_embedded_images - - def _make_mandrill_attachment(self, attachment, str_encoding=None): - """Returns EmailMessage.attachments item formatted for sending with Mandrill. - - Returns mandrill_dict, is_embedded_image: - mandrill_dict: {"type":..., "name":..., "content":...} - is_embedded_image: True if the attachment should instead be handled as an inline image. - - """ - # Note that an attachment can be either a tuple of (filename, content, - # mimetype) or a MIMEBase object. (Also, both filename and mimetype may - # be missing.) - is_embedded_image = False - if isinstance(attachment, MIMEBase): - name = attachment.get_filename() - content = attachment.get_payload(decode=True) - mimetype = attachment.get_content_type() - # Treat image attachments that have content ids as embedded: - if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None: - is_embedded_image = True - name = attachment["Content-ID"] - else: - (name, content, mimetype) = attachment - - # Guess missing mimetype from filename, borrowed from - # django.core.mail.EmailMessage._create_attachment() - if mimetype is None and name is not None: - mimetype, _ = mimetypes.guess_type(name) - if mimetype is None: - mimetype = DEFAULT_ATTACHMENT_MIME_TYPE - - # b64encode requires bytes, so let's convert our content. - try: - # noinspection PyUnresolvedReferences - if isinstance(content, unicode): - # Python 2.X unicode string - content = content.encode(str_encoding) - except NameError: - # Python 3 doesn't differentiate between strings and unicode - # Convert python3 unicode str to bytes attachment: - if isinstance(content, str): - content = content.encode(str_encoding) - - content_b64 = b64encode(content) - - mandrill_attachment = { - 'type': mimetype, - 'name': name or "", - 'content': content_b64.decode('ascii'), - } - return mandrill_attachment, is_embedded_image - @classmethod def encode_date_for_mandrill(cls, dt): """Format a date or datetime for use as a Mandrill API date field diff --git a/anymail/tests/test_mandrill_send.py b/anymail/tests/test_mandrill_send.py index 5707c77..0330051 100644 --- a/anymail/tests/test_mandrill_send.py +++ b/anymail/tests/test_mandrill_send.py @@ -614,7 +614,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): self.assertEqual(sent, 1) # refused message is included in sent count -@override_settings(MANDRILL_SETTINGS={ +@override_settings(ANYMAIL_SEND_DEFAULTS={ 'from_name': 'Djrill Test', 'important': True, 'track_opens': True, @@ -632,7 +632,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): 'return_path_domain': 'example.com', 'google_analytics_domains': ['example.com/test'], 'google_analytics_campaign': ['UA-00000000-1'], - 'metadata': ['djrill'], + 'metadata': {'feature': 'global', 'plus': 'that'}, 'merge_language': 'mailchimp', 'global_merge_vars': {'TEST': 'djrill'}, 'async': True, @@ -670,7 +670,7 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['return_path_domain'], 'example.com') self.assertEqual(data['message']['google_analytics_domains'], ['example.com/test']) self.assertEqual(data['message']['google_analytics_campaign'], ['UA-00000000-1']) - self.assertEqual(data['message']['metadata'], ['djrill']) + self.assertEqual(data['message']['metadata'], {'feature': 'global', 'plus': 'that'}) self.assertEqual(data['message']['merge_language'], 'mailchimp') self.assertEqual(data['message']['global_merge_vars'], [{'name': 'TEST', 'content': 'djrill'}]) @@ -702,7 +702,7 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.message.return_path_domain = "override.example.com" self.message.google_analytics_domains = ['override.example.com'] self.message.google_analytics_campaign = ['UA-99999999-1'] - self.message.metadata = ['override'] + self.message.metadata = {'feature': 'message', 'also': 'this'} self.message.merge_language = 'handlebars' self.message.async = False self.message.ip_pool = "Bulk Pool" @@ -716,7 +716,7 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assertFalse(data['message']['auto_html']) self.assertFalse(data['message']['inline_css']) self.assertFalse(data['message']['url_strip_qs']) - self.assertEqual(data['message']['tags'], ['override']) + self.assertEqual(data['message']['tags'], ['djrill', 'override']) # tags are merged self.assertFalse(data['message']['preserve_recipients']) self.assertFalse(data['message']['view_content_link']) self.assertEqual(data['message']['subaccount'], 'override') @@ -725,7 +725,8 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['return_path_domain'], 'override.example.com') self.assertEqual(data['message']['google_analytics_domains'], ['override.example.com']) self.assertEqual(data['message']['google_analytics_campaign'], ['UA-99999999-1']) - self.assertEqual(data['message']['metadata'], ['override']) + # metadata is merged: + self.assertEqual(data['message']['metadata'], {'feature': 'message', 'also': 'this', 'plus': 'that'}) self.assertEqual(data['message']['merge_language'], 'handlebars') self.assertEqual(data['message']['global_merge_vars'], [{'name': 'TEST', 'content': 'djrill'}]) diff --git a/anymail/utils.py b/anymail/utils.py new file mode 100644 index 0000000..317adbc --- /dev/null +++ b/anymail/utils.py @@ -0,0 +1,135 @@ +import mimetypes +from base64 import b64encode +from email.mime.base import MIMEBase +from email.utils import parseaddr + +import six +from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE + + +UNSET = object() # Used as non-None default value + + +def combine(*args): + """ + Combines all non-UNSET args, by shallow merging mappings and concatenating sequences + + >>> combine({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET) + {'a': 1, 'b': 3, 'c': 4} + >>> combine([1, 2], UNSET, [3, 4], UNSET) + [1, 2, 3, 4] + >>> combine({'a': 1}, None, {'b': 2}) # None suppresses earlier args + {'b': 2} + >>> combine() + UNSET + + """ + result = UNSET + for value in args: + if value is None: + # None is a request to suppress any earlier values + result = UNSET + elif value is not UNSET: + if result is UNSET: + try: + result = value.copy() # will shallow merge if dict-like + except AttributeError: + result = value # will concatenate if sequence-like + else: + try: + result.update(value) # shallow merge if dict-like + except AttributeError: + result += value # concatenate if sequence-like + return result + + +def last(*args): + """Returns the last of its args which is not UNSET. + + (Essentially `combine` without the merge behavior) + + >>> last(1, 2, UNSET, 3, UNSET, UNSET) + 3 + >>> last(1, 2, None, UNSET) # None suppresses earlier args + UNSET + >>> last() + UNSET + + """ + for value in reversed(args): + if value is None: + # None is a request to suppress any earlier values + return UNSET + elif value is not UNSET: + return value + return UNSET + + +class ParsedEmail(object): + """A sanitized, full email address with separate name and email properties""" + + def __init__(self, address, encoding): + self.address = sanitize_address(address, encoding) + self._name = None + self._email = None + + def _parse(self): + if self._email is None: + self._name, self._email = parseaddr(self.address) + + def __str__(self): + return self.address + + @property + def name(self): + self._parse() + return self._name + + @property + def email(self): + self._parse() + return self._email + + +class Attachment(object): + """A normalized EmailMessage.attachments item with additional functionality + + Normalized to have these properties: + name: attachment filename; may be empty string; will be Content-ID for inline attachments + content + mimetype: the content type; guessed if not explicit + inline: bool, True if attachment has a Content-ID header + """ + + def __init__(self, attachment, encoding): + # Note that an attachment can be either a tuple of (filename, content, mimetype) + # or a MIMEBase object. (Also, both filename and mimetype may be missing.) + self._attachment = attachment + self.encoding = encoding # should we be checking attachment["Content-Encoding"] ??? + self.inline = False + + if isinstance(attachment, MIMEBase): + self.name = attachment.get_filename() + self.content = attachment.get_payload(decode=True) + self.mimetype = attachment.get_content_type() + # Treat image attachments that have content ids as inline: + if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None: + self.inline = True + self.name = attachment["Content-ID"] + else: + (self.name, self.content, self.mimetype) = attachment + + # Guess missing mimetype from filename, borrowed from + # django.core.mail.EmailMessage._create_attachment() + if self.mimetype is None and self.name is not None: + self.mimetype, _ = mimetypes.guess_type(self.name) + if self.mimetype is None: + self.mimetype = DEFAULT_ATTACHMENT_MIME_TYPE + + @property + def b64content(self): + """Content encoded as a base64 ascii string""" + content = self.content + if isinstance(content, six.text_type): + content = content.encode(self.encoding) + return b64encode(content).decode("ascii")