django-anymail-1.4/0000755000076500000240000000000013237125745015220 5ustar medmundsstaff00000000000000django-anymail-1.4/anymail/0000755000076500000240000000000013237125745016652 5ustar medmundsstaff00000000000000django-anymail-1.4/anymail/__init__.py0000644000076500000240000000023513236634307020761 0ustar medmundsstaff00000000000000# Expose package version at root of package from ._version import __version__, VERSION # NOQA: F401 default_app_config = 'anymail.apps.AnymailBaseConfig' django-anymail-1.4/anymail/_version.py0000644000076500000240000000030213237124164021035 0ustar medmundsstaff00000000000000VERSION = (1, 4) __version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN __minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version" django-anymail-1.4/anymail/apps.py0000644000076500000240000000042113236662721020163 0ustar medmundsstaff00000000000000from django.apps import AppConfig from django.core import checks from .checks import check_deprecated_settings class AnymailBaseConfig(AppConfig): name = 'anymail' verbose_name = "Anymail" def ready(self): checks.register(check_deprecated_settings) django-anymail-1.4/anymail/backends/0000755000076500000240000000000013237125745020424 5ustar medmundsstaff00000000000000django-anymail-1.4/anymail/backends/__init__.py0000644000076500000240000000000012664362570022525 0ustar medmundsstaff00000000000000django-anymail-1.4/anymail/backends/base.py0000644000076500000240000004454513236203646021720 0ustar medmundsstaff00000000000000from datetime import date, datetime import six from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused from ..message import AnymailStatus from ..signals import pre_send, post_send from ..utils import (Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy) class AnymailBaseBackend(BaseEmailBackend): """ Base Anymail email backend """ def __init__(self, *args, **kwargs): super(AnymailBaseBackend, self).__init__(*args, **kwargs) 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', 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) self.send_defaults = send_defaults 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 = AnymailStatus() if not self.run_pre_send(message): # (might modify message) return False # cancel send without error if not message.recipients(): return False payload = self.build_message_payload(message, self.send_defaults) response = self.post_to_esp(payload, message) message.anymail_status.esp_response = response recipient_status = self.parse_recipient_status(response, payload, message) message.anymail_status.set_recipient_status(recipient_status) self.run_post_send(message) # send signal before raising status errors self.raise_for_recipient_status(message.anymail_status, response, payload, message) return True def run_pre_send(self, message): """Send pre_send signal, and return True if message should still be sent""" try: pre_send.send(self.__class__, message=message, esp_name=self.esp_name) return True except AnymailCancelSend: return False # abort without causing error def run_post_send(self, message): """Send post_send signal to all receivers""" results = post_send.send_robust( self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name) for (receiver, response) in results: if isinstance(response, Exception): raise response def build_message_payload(self, message, defaults): """Returns a payload that will allow message to be sent via the ESP. Derived classes must implement, and should subclass :class:BasePayload to get standard Anymail options. Raises :exc:AnymailUnsupportedFeature for message options that cannot be communicated to the ESP. :param message: :class:EmailMessage :param defaults: dict :return: :class:BasePayload """ 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 post_to_esp" % (self.__class__.__module__, self.__class__.__name__)) def parse_recipient_status(self, response, payload, message): """Return a dict mapping email to AnymailRecipientStatus for each recipient. Can raise AnymailAPIError (or derived exception) if response is unparsable """ raise NotImplementedError("%s.%s must implement parse_recipient_status" % (self.__class__.__module__, self.__class__.__name__)) def raise_for_recipient_status(self, anymail_status, response, payload, message): """If *all* recipients are refused or invalid, raises AnymailRecipientsRefused""" if not self.ignore_recipient_status: # Error if *all* recipients are invalid or refused # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) if anymail_status.status.issubset({"invalid", "rejected"}): raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response, backend=self) @property def esp_name(self): """ Read-only name of the ESP for this backend. Concrete backends must override with class attr. E.g.: esp_name = "Postmark" esp_name = "SendGrid" # (use ESP's preferred capitalization) """ raise NotImplementedError("%s.%s must declare esp_name class attr" % (self.__class__.__module__, self.__class__.__name__)) class BasePayload(object): # Listing of EmailMessage/EmailMultiAlternatives attributes # to process into Payload. Each item is in the form: # (attr, combiner, converter) # attr: the property name # combiner: optional function(default_value, value) -> value # to combine settings defaults with the EmailMessage property value # (usually `combine` to merge, or `last` for message value to override default; # use `None` if settings defaults aren't supported) # converter: optional function(value) -> value transformation # (can be a callable or the string name of a Payload method, or `None`) # The converter must force any Django lazy translation strings to text. # The Payload's `set_` method will be called with # the combined/converted results for each attr. base_message_attrs = ( # Standard EmailMessage/EmailMultiAlternatives props ('from_email', last, parse_address_list), # multiple from_emails are allowed ('to', combine, parse_address_list), ('cc', combine, parse_address_list), ('bcc', combine, parse_address_list), ('subject', last, force_non_lazy), ('reply_to', combine, parse_address_list), ('extra_headers', combine, force_non_lazy_dict), ('body', last, force_non_lazy), # special handling below checks message.content_subtype ('alternatives', combine, 'prepped_alternatives'), ('attachments', combine, 'prepped_attachments'), ) anymail_message_attrs = ( # Anymail expando-props ('metadata', combine, force_non_lazy_dict), ('send_at', last, 'aware_datetime'), ('tags', combine, force_non_lazy_list), ('track_clicks', last, None), ('track_opens', last, None), ('template_id', last, force_non_lazy), ('merge_data', combine, force_non_lazy_dict), ('merge_global_data', combine, force_non_lazy_dict), ('esp_extra', combine, force_non_lazy_dict), ) esp_message_attrs = () # subclasses can override def __init__(self, message, defaults, backend): self.message = message self.defaults = defaults self.backend = backend self.esp_name = backend.esp_name self.init_payload() # we should consider hoisting the first text/html out of alternatives into set_html_body message_attrs = self.base_message_attrs + self.anymail_message_attrs + self.esp_message_attrs for attr, combiner, converter in message_attrs: value = getattr(message, attr, UNSET) if attr in ('to', 'cc', 'bcc', 'reply_to') and value is not UNSET: self.validate_not_bare_string(attr, value) if combiner is not None: default_value = self.defaults.get(attr, UNSET) value = combiner(default_value, value) if value is not UNSET: if converter is not None: if not callable(converter): converter = getattr(self, converter) value = converter(value) if value is not UNSET: if attr == 'body': setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body elif attr == 'from_email': setter = self.set_from_email_list else: # AttributeError here? Your Payload subclass is missing a set_ implementation setter = getattr(self, 'set_%s' % attr) setter(value) def unsupported_feature(self, feature): if not self.backend.ignore_unsupported_features: raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature), email_message=self.message, payload=self, backend=self.backend) # # Attribute validators # def validate_not_bare_string(self, attr, value): """EmailMessage to, cc, bcc, and reply_to are specced to be lists of strings. This catches the common error where a single string is used instead. (See also checks in EmailMessage.__init__.) """ # Note: this actually only runs for reply_to. If to, cc, or bcc are # set to single strings, you'll end up with an earlier cryptic TypeError # from EmailMesssage.recipients (called from EmailMessage.send) before # the Anymail backend even gets involved: # TypeError: must be str, not list # TypeError: can only concatenate list (not "str") to list # TypeError: Can't convert 'list' object to str implicitly if isinstance(value, six.string_types) or is_lazy(value): raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr)) # # Attribute converters # def prepped_alternatives(self, alternatives): return [(force_non_lazy(content), mimetype) for (content, mimetype) in alternatives] def prepped_attachments(self, attachments): str_encoding = self.message.encoding or settings.DEFAULT_CHARSET return [Attachment(attachment, str_encoding) # (handles lazy content, filename) for attachment in attachments] def aware_datetime(self, value): """Converts a date or datetime or timestamp to an aware datetime. Naive datetimes are assumed to be in Django's current_timezone. Dates are interpreted as midnight that date, in Django's current_timezone. Integers are interpreted as POSIX timestamps (which are inherently UTC). Anything else (e.g., str) is returned unchanged, which won't be portable. """ if isinstance(value, datetime): dt = value else: if isinstance(value, date): dt = datetime(value.year, value.month, value.day) # naive, midnight else: try: dt = datetime.utcfromtimestamp(value).replace(tzinfo=utc) except (TypeError, ValueError): return value if is_naive(dt): dt = make_aware(dt, get_current_timezone()) return dt # # Abstract implementation # def init_payload(self): raise NotImplementedError("%s.%s must implement init_payload" % (self.__class__.__module__, self.__class__.__name__)) def set_from_email_list(self, emails): # If your backend supports multiple from emails, override this to handle the whole list; # otherwise just implement set_from_email if len(emails) > 1: self.unsupported_feature("multiple from emails") # fall through if ignoring unsupported features if len(emails) > 0: self.set_from_email(emails[0]) def set_from_email(self, email): raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" % (self.__class__.__module__, self.__class__.__name__)) def set_to(self, emails): return self.set_recipients('to', emails) def set_cc(self, emails): return self.set_recipients('cc', emails) def set_bcc(self, emails): return self.set_recipients('bcc', emails) def set_recipients(self, recipient_type, emails): for email in emails: self.add_recipient(recipient_type, email) def add_recipient(self, recipient_type, email): raise NotImplementedError("%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}" % (self.__class__.__module__, self.__class__.__name__)) def set_subject(self, subject): raise NotImplementedError("%s.%s must implement set_subject" % (self.__class__.__module__, self.__class__.__name__)) def set_reply_to(self, emails): self.unsupported_feature('reply_to') def set_extra_headers(self, headers): self.unsupported_feature('extra_headers') def set_text_body(self, body): raise NotImplementedError("%s.%s must implement set_text_body" % (self.__class__.__module__, self.__class__.__name__)) def set_html_body(self, body): raise NotImplementedError("%s.%s must implement set_html_body" % (self.__class__.__module__, self.__class__.__name__)) def set_alternatives(self, alternatives): for content, mimetype in alternatives: if mimetype == "text/html": # This assumes that there's at most one html alternative, # and so it should be the html body. (Most ESPs don't # support multiple html alternative parts anyway.) self.set_html_body(content) else: self.add_alternative(content, mimetype) def add_alternative(self, content, mimetype): self.unsupported_feature("alternative part with type '%s'" % mimetype) def set_attachments(self, attachments): for attachment in attachments: self.add_attachment(attachment) def add_attachment(self, attachment): raise NotImplementedError("%s.%s must implement add_attachment or set_attachments" % (self.__class__.__module__, self.__class__.__name__)) # Anymail-specific payload construction def set_metadata(self, metadata): self.unsupported_feature("metadata") def set_send_at(self, send_at): self.unsupported_feature("send_at") def set_tags(self, tags): self.unsupported_feature("tags") def set_track_clicks(self, track_clicks): self.unsupported_feature("track_clicks") def set_track_opens(self, track_opens): self.unsupported_feature("track_opens") def set_template_id(self, template_id): self.unsupported_feature("template_id") def set_merge_data(self, merge_data): self.unsupported_feature("merge_data") def set_merge_global_data(self, merge_global_data): self.unsupported_feature("merge_global_data") # ESP-specific payload construction def set_esp_extra(self, extra): self.unsupported_feature("esp_extra") django-anymail-1.4/anymail/backends/base_requests.py0000644000076500000240000001403413236203646023641 0ustar medmundsstaff00000000000000import json import requests # noinspection PyUnresolvedReferences from six.moves.urllib.parse import urljoin from anymail.utils import get_anymail_setting from .base import AnymailBaseBackend, BasePayload from ..exceptions import AnymailRequestsAPIError, AnymailSerializationError from .._version import __version__ 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 self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30) 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"] = "django-anymail/{version}-{esp} {orig}".format( esp=self.esp_name.lower(), version=__version__, orig=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 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 """ params = payload.get_request_params(self.api_url) params.setdefault('timeout', self.timeout) try: response = self.session.request(**params) except requests.RequestException as err: # raise an exception that is both AnymailRequestsAPIError # and the original requests exception type exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {}) raise exc_class( "Error posting to %s:" % params.get('url', ''), raised_from=err, email_message=message, payload=payload) self.raise_for_status(response, payload, message) return response def raise_for_status(self, response, payload, message): """Raise AnymailRequestsAPIError if response is an HTTP error Subclasses can override for custom error checking (though should defer parsing/deserialization of the body to parse_recipient_status) """ if response.status_code != 200: raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) def deserialize_json_response(self, response, payload, message): """Deserialize an ESP API response that's in json. Useful for implementing deserialize_response """ try: return response.json() except ValueError: raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, email_message=message, payload=payload, response=response, backend=self) class RequestsPayload(BasePayload): """Abstract Payload for AnymailRequestsBackend""" def __init__(self, message, defaults, backend, method="POST", params=None, data=None, headers=None, files=None, auth=None): self.method = method self.params = params self.data = data self.headers = headers self.files = files self.auth = auth super(RequestsPayload, self).__init__(message, defaults, backend) def get_request_params(self, api_url): """Returns a dict of requests.request params that will send payload to the ESP. :param api_url: the base api_url for the backend :return: dict """ api_endpoint = self.get_api_endpoint() if api_endpoint is not None: url = urljoin(api_url, api_endpoint) else: url = api_url return dict( method=self.method, url=url, params=self.params, data=self.serialize_data(), headers=self.headers, files=self.files, auth=self.auth, # json= is not here, because we prefer to do our own serialization # to provide extra context in error messages ) def get_api_endpoint(self): """Returns a str that should be joined to the backend's api_url for sending this payload.""" return None def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" return self.data def serialize_json(self, data): """Returns data serialized to json, raising appropriate errors. Useful for implementing serialize_data in a subclass, """ try: return json.dumps(data) except TypeError as err: # Add some context to the "not JSON serializable" message raise AnymailSerializationError(orig_err=err, email_message=self.message, backend=self.backend, payload=self) django-anymail-1.4/anymail/backends/console.py0000644000076500000240000000252313236203646022436 0ustar medmundsstaff00000000000000import uuid from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend from ..exceptions import AnymailError from .test import EmailBackend as AnymailTestBackend class EmailBackend(AnymailTestBackend, DjangoConsoleBackend): """ Anymail backend that prints messages to the console, while retaining anymail statuses and signals. """ esp_name = "Console" def get_esp_message_id(self, message): # Generate a guaranteed-unique ID for the message return str(uuid.uuid4()) def send_messages(self, email_messages): if not email_messages: return msg_count = 0 with self._lock: try: stream_created = self.open() for message in email_messages: try: sent = self._send(message) except AnymailError: if self.fail_silently: sent = False else: raise if sent: self.write_message(message) self.stream.flush() # flush after each message msg_count += 1 finally: if stream_created: self.close() return msg_count django-anymail-1.4/anymail/backends/mailgun.py0000644000076500000240000002055213236203646022432 0ustar medmundsstaff00000000000000from datetime import datetime from ..exceptions import AnymailRequestsAPIError, AnymailError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, rfc2822date from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mailgun API Email Backend """ esp_name = "Mailgun" def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) self.sender_domain = get_anymail_setting('sender_domain', esp_name=esp_name, kwargs=kwargs, allow_bare=True, default=None) 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(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailgunPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): # The *only* 200 response from Mailgun seems to be: # { # "id": "<20160306015544.116301.25145@example.org>", # "message": "Queued. Thank you." # } # # That single message id applies to all recipients. # The only way to detect rejected, etc. is via webhooks. # (*Any* invalid recipient addresses will generate a 400 API error) parsed_response = self.deserialize_json_response(response, payload, message) try: message_id = parsed_response["id"] mailgun_message = parsed_response["message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mailgun API response format", email_message=message, payload=payload, response=response, backend=self) if not mailgun_message.startswith("Queued"): raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message, email_message=message, payload=payload, response=response, backend=self) # Simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} class MailgunPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): auth = ("api", backend.api_key) self.sender_domain = backend.sender_domain self.all_recipients = [] # used for backend.parse_recipient_status # late-binding of recipient-variables: self.merge_data = None self.merge_global_data = None self.to_emails = [] super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs) def get_api_endpoint(self): if self.sender_domain is None: raise AnymailError("Cannot call Mailgun unknown sender domain. " "Either provide valid `from_email`, " "or set `message.esp_extra={'sender_domain': 'example.com'}`", backend=self.backend, email_message=self.message, payload=self) return "%s/messages" % self.sender_domain def serialize_data(self): self.populate_recipient_variables() return self.data def populate_recipient_variables(self): """Populate Mailgun recipient-variables header from merge data""" merge_data = self.merge_data if self.merge_global_data is not None: # Mailgun doesn't support global variables. # We emulate them by populating recipient-variables for all recipients. if merge_data is not None: merge_data = merge_data.copy() # don't modify the original, which doesn't belong to us else: merge_data = {} for email in self.to_emails: try: recipient_data = merge_data[email] except KeyError: merge_data[email] = self.merge_global_data else: # Merge globals (recipient_data wins in conflict) merge_data[email] = self.merge_global_data.copy() merge_data[email].update(recipient_data) if merge_data is not None: self.data['recipient-variables'] = self.serialize_json(merge_data) # # Payload construction # def init_payload(self): self.data = {} # {field: [multiple, values]} self.files = [] # [(field, multiple), (field, values)] def set_from_email_list(self, emails): # Mailgun supports multiple From email addresses self.data["from"] = [email.address for email in emails] if self.sender_domain is None and len(emails) > 0: # try to intuit sender_domain from first from_email self.sender_domain = emails[0].domain or None def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [email.address for email in emails] self.all_recipients += emails # used for backend.parse_recipient_status if recipient_type == 'to': self.to_emails = [email.addr_spec for email in emails] # used for populate_recipient_variables def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails): if emails: reply_to = ", ".join([str(email) for email in emails]) self.data["h:Reply-To"] = reply_to def set_extra_headers(self, headers): for key, value in headers.items(): self.data["h:%s" % key] = value def set_text_body(self, body): self.data["text"] = body def set_html_body(self, body): if "html" in self.data: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body def add_attachment(self, attachment): # http://docs.python-requests.org/en/v2.4.3/user/advanced/#post-multiple-multipart-encoded-files if attachment.inline: field = "inline" name = attachment.cid else: field = "attachment" name = attachment.name self.files.append( (field, (name, attachment.content, attachment.mimetype)) ) def set_metadata(self, metadata): for key, value in metadata.items(): self.data["v:%s" % key] = value def set_send_at(self, send_at): # Mailgun expects RFC-2822 format dates # (BasePayload has converted most date-like values to datetime by now; # if the caller passes a string, they'll need to format it themselves.) if isinstance(send_at, datetime): send_at = rfc2822date(send_at) self.data["o:deliverytime"] = send_at def set_tags(self, tags): self.data["o:tag"] = tags def set_track_clicks(self, track_clicks): # Mailgun also supports an "htmlonly" option, which Anymail doesn't offer self.data["o:tracking-clicks"] = "yes" if track_clicks else "no" def set_track_opens(self, track_opens): self.data["o:tracking-opens"] = "yes" if track_opens else "no" # template_id: Mailgun doesn't offer stored templates. # (The message body and other fields *are* the template content.) def set_merge_data(self, merge_data): # Processed at serialization time (to allow merging global data) self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # Processed at serialization time (to allow merging global data) self.merge_global_data = merge_global_data def set_esp_extra(self, extra): self.data.update(extra) # Allow override of sender_domain via esp_extra # (but pop it out of params to send to Mailgun) self.sender_domain = self.data.pop("sender_domain", self.sender_domain) django-anymail-1.4/anymail/backends/mailjet.py0000644000076500000240000002572713236203646022434 0ustar medmundsstaff00000000000000from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES from ..utils import get_anymail_setting, EmailAddress, parse_address_list from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mailjet API Email Backend """ esp_name = "Mailjet" def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) self.secret_key = get_anymail_setting('secret_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.mailjet.com/v3") if not api_url.endswith("/"): api_url += "/" super(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailjetPayload(message, defaults, self) def raise_for_status(self, response, payload, message): # Improve Mailjet's (lack of) error message for bad API key if response.status_code == 401 and not response.content: raise AnymailRequestsAPIError( "Invalid Mailjet API key or secret", email_message=message, payload=payload, response=response, backend=self) super(EmailBackend, self).raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): # Mailjet's (v3.0) transactional send API is not covered in their reference docs. # The response appears to be either: # {"Sent": [{"Email": ..., "MessageID": ...}, ...]} # where only successful recipients are included # or if the entire call has failed: # {"ErrorCode": nnn, "Message": ...} parsed_response = self.deserialize_json_response(response, payload, message) if "ErrorCode" in parsed_response: raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) recipient_status = {} try: for key in parsed_response: status = key.lower() if status not in ANYMAIL_STATUSES: status = 'unknown' for item in parsed_response[key]: message_id = str(item['MessageID']) email = item['Email'] recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mailjet API response format", email_message=message, payload=payload, response=response, backend=self) # Make sure we ended up with a status for every original recipient # (Mailjet only communicates "Sent") for recipients in payload.recipients.values(): for email in recipients: if email.addr_spec not in recipient_status: recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='unknown') return recipient_status class MailjetPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): self.esp_extra = {} # late-bound in serialize_data auth = (backend.api_key, backend.secret_key) http_headers = { 'Content-Type': 'application/json', } # Late binding of recipients and their variables self.recipients = {} self.merge_data = None super(MailjetPayload, self).__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "send" def serialize_data(self): self._finish_recipients() self._populate_sender_from_template() return self.serialize_json(self.data) # # Payload construction # def _finish_recipients(self): # NOTE do not set both To and Recipients, it behaves specially: each # recipient receives a separate mail but the To address receives one # listing all recipients. if "cc" in self.recipients or "bcc" in self.recipients: self._finish_recipients_single() else: self._finish_recipients_with_vars() def _populate_sender_from_template(self): # If no From address was given, use the address from the template. # Unfortunately, API 3.0 requires the From address to be given, so let's # query it when needed. This will supposedly be fixed in 3.1 with a # public beta in May 2017. template_id = self.data.get("Mj-TemplateID") if template_id and not self.data.get("FromEmail"): response = self.backend.session.get( "%sREST/template/%s/detailcontent" % (self.backend.api_url, template_id), auth=self.auth, timeout=self.backend.timeout ) self.backend.raise_for_status(response, None, self.message) json_response = self.backend.deserialize_json_response(response, None, self.message) # Populate email address header from template. try: headers = json_response["Data"][0]["Headers"] if "From" in headers: # Workaround Mailjet returning malformed From header # if there's a comma in the template's From display-name: from_email = headers["From"].replace(",", "||COMMA||") parsed = parse_address_list([from_email])[0] if parsed.display_name: parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","), parsed.addr_spec) else: parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"]) except KeyError: raise AnymailRequestsAPIError("Invalid Mailjet template API response", email_message=self.message, response=response, backend=self.backend) self.set_from_email(parsed) def _finish_recipients_with_vars(self): """Send bulk mail with different variables for each mail.""" assert "Cc" not in self.data and "Bcc" not in self.data recipients = [] merge_data = self.merge_data or {} for email in self.recipients["to"]: recipient = { "Email": email.addr_spec, "Name": email.display_name, "Vars": merge_data.get(email.addr_spec) } # Strip out empty Name and Vars recipient = {k: v for k, v in recipient.items() if v} recipients.append(recipient) self.data["Recipients"] = recipients def _finish_recipients_single(self): """Send a single mail with some To, Cc and Bcc headers.""" assert "Recipients" not in self.data if self.merge_data: # When Cc and Bcc headers are given, then merge data cannot be set. raise NotImplementedError("Cannot set merge data with bcc/cc") for recipient_type, emails in self.recipients.items(): # Workaround Mailjet 3.0 bug parsing display-name with commas # (see test_comma_in_display_name in test_mailjet_backend for details) formatted_emails = [ email.address if "," not in email.display_name # else name has a comma, so force it into MIME encoded-word utf-8 syntax: else EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8') for email in emails ] self.data[recipient_type.capitalize()] = ", ".join(formatted_emails) def init_payload(self): self.data = { } def set_from_email(self, email): self.data["FromEmail"] = email.addr_spec if email.display_name: self.data["FromName"] = email.display_name def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] # Will be handled later in serialize_data if emails: self.recipients[recipient_type] = emails def set_subject(self, subject): self.data["Subject"] = subject def set_reply_to(self, emails): headers = self.data.setdefault("Headers", {}) if emails: headers["Reply-To"] = ", ".join([str(email) for email in emails]) elif "Reply-To" in headers: del headers["Reply-To"] def set_extra_headers(self, headers): self.data.setdefault("Headers", {}).update(headers) def set_text_body(self, body): self.data["Text-part"] = body def set_html_body(self, body): if "Html-part" in self.data: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.data["Html-part"] = body def add_attachment(self, attachment): if attachment.inline: field = "Inline_attachments" name = attachment.cid else: field = "Attachments" name = attachment.name or "" self.data.setdefault(field, []).append({ "Content-type": attachment.mimetype, "Filename": name, "content": attachment.b64content }) def set_metadata(self, metadata): # Mailjet expects a single string payload self.data["Mj-EventPayLoad"] = self.serialize_json(metadata) def set_tags(self, tags): # The choices here are CustomID or Campaign, and Campaign seems closer # to how "tags" are handled by other ESPs -- e.g., you can view dashboard # statistics across all messages with the same Campaign. if len(tags) > 0: self.data["Tag"] = tags[0] self.data["Mj-campaign"] = tags[0] if len(tags) > 1: self.unsupported_feature('multiple tags (%r)' % tags) def set_track_clicks(self, track_clicks): # 1 disables tracking, 2 enables tracking self.data["Mj-trackclick"] = 2 if track_clicks else 1 def set_track_opens(self, track_opens): # 1 disables tracking, 2 enables tracking self.data["Mj-trackopen"] = 2 if track_opens else 1 def set_template_id(self, template_id): self.data["Mj-TemplateID"] = template_id self.data["Mj-TemplateLanguage"] = True def set_merge_data(self, merge_data): # Will be handled later in serialize_data self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): self.data["Vars"] = merge_global_data def set_esp_extra(self, extra): self.data.update(extra) django-anymail-1.4/anymail/backends/mandrill.py0000644000076500000240000003015513236203646022600 0ustar medmundsstaff00000000000000import warnings from datetime import datetime from ..exceptions import AnymailRequestsAPIError, AnymailWarning from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES from ..utils import last, combine, get_anymail_setting from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mandrill API Email Backend """ esp_name = "Mandrill" def __init__(self, **kwargs): """Init options from Django settings""" 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(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MandrillPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) recipient_status = {} try: # Mandrill returns a list of { email, status, _id, reject_reason } for each recipient for item in parsed_response: email = item['email'] status = item['status'] if status not in ANYMAIL_STATUSES: status = 'unknown' message_id = item.get('_id', None) # can be missing for invalid/rejected recipients recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Mandrill API response format", email_message=message, payload=payload, response=response, backend=self) return recipient_status class DjrillDeprecationWarning(AnymailWarning, DeprecationWarning): """Warning for features carried over from Djrill that will be removed soon""" def encode_date_for_mandrill(dt): """Format a datetime for use as a Mandrill API date field Mandrill expects "YYYY-MM-DD HH:MM:SS" in UTC """ if isinstance(dt, datetime): dt = dt.replace(microsecond=0) if dt.utcoffset() is not None: dt = (dt - dt.utcoffset()).replace(tzinfo=None) return dt.isoformat(' ') else: return dt class MandrillPayload(RequestsPayload): def __init__(self, *args, **kwargs): self.esp_extra = {} # late-bound in serialize_data super(MandrillPayload, self).__init__(*args, **kwargs) def get_api_endpoint(self): if 'template_name' in self.data: return "messages/send-template.json" else: return "messages/send.json" def serialize_data(self): self.process_esp_extra() return self.serialize_json(self.data) # # Payload construction # def init_payload(self): self.data = { "key": self.backend.api_key, "message": {}, } def set_from_email(self, email): if getattr(self.message, "use_template_from", False): self.deprecation_warning('message.use_template_from', 'message.from_email = None') else: self.data["message"]["from_email"] = email.addr_spec if email.display_name: self.data["message"]["from_name"] = email.display_name def add_recipient(self, recipient_type, email): assert recipient_type in ["to", "cc", "bcc"] to_list = self.data["message"].setdefault("to", []) to_list.append({"email": email.addr_spec, "name": email.display_name, "type": recipient_type}) def set_subject(self, subject): if getattr(self.message, "use_template_subject", False): self.deprecation_warning('message.use_template_subject', 'message.subject = None') else: self.data["message"]["subject"] = subject def set_reply_to(self, emails): reply_to = ", ".join([str(email) for email in emails]) self.data["message"].setdefault("headers", {})["Reply-To"] = reply_to def set_extra_headers(self, headers): self.data["message"].setdefault("headers", {}).update(headers) def set_text_body(self, body): self.data["message"]["text"] = body def set_html_body(self, body): if "html" in self.data["message"]: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.data["message"]["html"] = body def add_attachment(self, attachment): if attachment.inline: field = "images" name = attachment.cid else: field = "attachments" name = attachment.name or "" self.data["message"].setdefault(field, []).append({ "type": attachment.mimetype, "name": name, "content": attachment.b64content }) def set_metadata(self, metadata): self.data["message"]["metadata"] = metadata def set_send_at(self, send_at): self.data["send_at"] = encode_date_for_mandrill(send_at) def set_tags(self, tags): self.data["message"]["tags"] = tags def set_track_clicks(self, track_clicks): self.data["message"]["track_clicks"] = track_clicks def set_track_opens(self, track_opens): self.data["message"]["track_opens"] = track_opens def set_template_id(self, template_id): self.data["template_name"] = template_id self.data.setdefault("template_content", []) # Mandrill requires something here def set_merge_data(self, merge_data): self.data['message']['preserve_recipients'] = False # if merge, hide recipients from each other self.data['message']['merge_vars'] = [ {'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]} for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility for rcpt, rcpt_data in merge_data.items() ] def set_merge_global_data(self, merge_global_data): self.data['message']['global_merge_vars'] = [ {'name': var, 'content': value} for var, value in merge_global_data.items() ] def set_esp_extra(self, extra): # late bind in serialize_data, so that obsolete Djrill attrs can contribute self.esp_extra = extra def process_esp_extra(self): if self.esp_extra is not None and len(self.esp_extra) > 0: esp_extra = self.esp_extra # Convert pythonic template_content dict to Mandrill name/content list try: template_content = esp_extra['template_content'] except KeyError: pass else: if hasattr(template_content, 'items'): # if it's dict-like if esp_extra is self.esp_extra: esp_extra = self.esp_extra.copy() # don't modify caller's value esp_extra['template_content'] = [ {'name': var, 'content': value} for var, value in template_content.items()] # Convert pythonic recipient_metadata dict to Mandrill rcpt/values list try: recipient_metadata = esp_extra['message']['recipient_metadata'] except KeyError: pass else: if hasattr(recipient_metadata, 'keys'): # if it's dict-like if esp_extra['message'] is self.esp_extra['message']: esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value # For testing reproducibility, we sort the recipients esp_extra['message']['recipient_metadata'] = [ {'rcpt': rcpt, 'values': recipient_metadata[rcpt]} for rcpt in sorted(recipient_metadata.keys())] # Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys self.data.update({k: v for k, v in esp_extra.items() if k != 'message'}) try: self.data['message'].update(esp_extra['message']) except KeyError: pass # Djrill deprecated message attrs def deprecation_warning(self, feature, replacement=None): msg = "Djrill's `%s` will be removed in an upcoming Anymail release." % feature if replacement: msg += " Use `%s` instead." % replacement warnings.warn(msg, DjrillDeprecationWarning) def deprecated_to_esp_extra(self, attr, in_message_dict=False): feature = "message.%s" % attr if in_message_dict: replacement = "message.esp_extra = {'message': {'%s': }}" % attr else: replacement = "message.esp_extra = {'%s': }" % attr self.deprecation_warning(feature, replacement) esp_message_attrs = ( ('async', last, None), ('ip_pool', last, None), ('from_name', last, None), # overrides display name parsed from from_email above ('important', last, None), ('auto_text', last, None), ('auto_html', last, None), ('inline_css', last, None), ('url_strip_qs', last, None), ('tracking_domain', last, None), ('signing_domain', last, None), ('return_path_domain', last, None), ('merge_language', last, None), ('preserve_recipients', last, None), ('view_content_link', last, None), ('subaccount', last, None), ('google_analytics_domains', last, None), ('google_analytics_campaign', last, None), ('global_merge_vars', combine, None), ('merge_vars', combine, None), ('recipient_metadata', combine, None), ('template_name', last, None), ('template_content', combine, None), ) def set_async(self, async): self.deprecated_to_esp_extra('async') self.esp_extra['async'] = async def set_ip_pool(self, ip_pool): self.deprecated_to_esp_extra('ip_pool') self.esp_extra['ip_pool'] = ip_pool def set_global_merge_vars(self, global_merge_vars): self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data') self.set_merge_global_data(global_merge_vars) def set_merge_vars(self, merge_vars): self.deprecation_warning('message.merge_vars', 'message.merge_data') self.set_merge_data(merge_vars) def set_template_name(self, template_name): self.deprecation_warning('message.template_name', 'message.template_id') self.set_template_id(template_name) def set_template_content(self, template_content): self.deprecated_to_esp_extra('template_content') self.esp_extra['template_content'] = template_content def set_recipient_metadata(self, recipient_metadata): self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True) self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata # Set up simple set_ functions for any missing esp_message_attrs attrs # (avoids dozens of simple `self.data["message"][] = value` functions) @classmethod def define_message_attr_setters(cls): for (attr, _, _) in cls.esp_message_attrs: setter_name = 'set_%s' % attr try: getattr(cls, setter_name) except AttributeError: setter = cls.make_setter(attr, setter_name) setattr(cls, setter_name, setter) @staticmethod def make_setter(attr, setter_name): # sure wish we could use functools.partial to create instance methods (descriptors) def setter(self, value): self.deprecated_to_esp_extra(attr, in_message_dict=True) self.esp_extra.setdefault('message', {})[attr] = value setter.__name__ = setter_name return setter MandrillPayload.define_message_attr_setters() django-anymail-1.4/anymail/backends/postmark.py0000644000076500000240000002057513236203646022643 0ustar medmundsstaff00000000000000import re from requests.structures import CaseInsensitiveDict from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Postmark API Email Backend """ esp_name = "Postmark" def __init__(self, **kwargs): """Init options from Django settings""" 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(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return PostmarkPayload(message, defaults, self) def raise_for_status(self, response, payload, message): # We need to handle 422 responses in parse_recipient_status if response.status_code != 422: super(EmailBackend, self).raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) try: error_code = parsed_response["ErrorCode"] msg = parsed_response["Message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid Postmark API response format", email_message=message, payload=payload, response=response, backend=self) message_id = parsed_response.get("MessageID", None) rejected_emails = [] if error_code == 300: # Invalid email request # Either the From address or at least one recipient was invalid. Email not sent. if "'From' address" in msg: # Normal error raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) else: # Use AnymailRecipientsRefused logic default_status = 'invalid' elif error_code == 406: # Inactive recipient # All recipients were rejected as hard-bounce or spam-complaint. Email not sent. default_status = 'rejected' elif error_code == 0: # At least partial success, and email was sent. # Sadly, have to parse human-readable message to figure out if everyone got it. default_status = 'sent' rejected_emails = self.parse_inactive_recipients(msg) else: raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) return { recipient.addr_spec: AnymailRecipientStatus( message_id=message_id, status=('rejected' if recipient.addr_spec.lower() in rejected_emails else default_status) ) for recipient in payload.all_recipients } def parse_inactive_recipients(self, msg): """Return a list of 'inactive' email addresses from a Postmark "OK" response :param str msg: the "Message" from the Postmark API response """ # Example msg with inactive recipients: # "Message OK, but will not deliver to these inactive addresses: one@xample.com, two@example.com." # " Inactive recipients are ones that have generated a hard bounce or a spam complaint." # Example msg with everything OK: "OK" match = re.search(r'inactive addresses:\s*(.*)\.\s*Inactive recipients', msg) if match: emails = match.group(1) # "one@xample.com, two@example.com" return [email.strip().lower() for email in emails.split(',')] else: return [] class PostmarkPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', # 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra) } self.server_token = backend.server_token # added to headers later, so esp_extra can override self.all_recipients = [] # used for backend.parse_recipient_status super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): if 'TemplateId' in self.data or 'TemplateModel' in self.data: # This is the one Postmark API documented to have a trailing slash. (Typo?) return "email/withTemplate/" else: return "email" def get_request_params(self, api_url): params = super(PostmarkPayload, self).get_request_params(api_url) params['headers']['X-Postmark-Server-Token'] = self.server_token return params def serialize_data(self): return self.serialize_json(self.data) # # Payload construction # def init_payload(self): self.data = {} # becomes json def set_from_email_list(self, emails): # Postmark accepts multiple From email addresses # (though truncates to just the first, on their end, as of 4/2017) self.data["From"] = ", ".join([email.address for email in emails]) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: field = recipient_type.capitalize() self.data[field] = ', '.join([email.address for email in emails]) self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): self.data["Subject"] = subject def set_reply_to(self, emails): if emails: reply_to = ", ".join([email.address for email in emails]) self.data["ReplyTo"] = reply_to def set_extra_headers(self, headers): header_dict = CaseInsensitiveDict(headers) if 'Reply-To' in header_dict: self.data["ReplyTo"] = header_dict.pop('Reply-To') self.data["Headers"] = [ {"Name": key, "Value": value} for key, value in header_dict.items() ] def set_text_body(self, body): self.data["TextBody"] = body def set_html_body(self, body): if "HtmlBody" in self.data: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.data["HtmlBody"] = body def make_attachment(self, attachment): """Returns Postmark attachment dict for attachment""" att = { "Name": attachment.name or "", "Content": attachment.b64content, "ContentType": attachment.mimetype, } if attachment.inline: att["ContentID"] = "cid:%s" % attachment.cid return att def set_attachments(self, attachments): if attachments: self.data["Attachments"] = [ self.make_attachment(attachment) for attachment in attachments ] # Postmark doesn't support metadata # def set_metadata(self, metadata): # Postmark doesn't support delayed sending # def set_send_at(self, send_at): def set_tags(self, tags): if len(tags) > 0: self.data["Tag"] = tags[0] if len(tags) > 1: self.unsupported_feature('multiple tags (%r)' % tags) def set_track_clicks(self, track_clicks): self.data["TrackLinks"] = 'HtmlAndText' if track_clicks else 'None' def set_track_opens(self, track_opens): self.data["TrackOpens"] = track_opens def set_template_id(self, template_id): self.data["TemplateId"] = template_id # merge_data: Postmark doesn't support per-recipient substitutions def set_merge_global_data(self, merge_global_data): self.data["TemplateModel"] = merge_global_data def set_esp_extra(self, extra): self.data.update(extra) # Special handling for 'server_token': self.server_token = self.data.pop('server_token', self.server_token) django-anymail-1.4/anymail/backends/sendgrid.py0000644000076500000240000003764713236203646022612 0ustar medmundsstaff00000000000000from email.utils import quote as rfc822_quote import warnings from django.core.mail import make_msgid from requests.structures import CaseInsensitiveDict from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning from ..message import AnymailRecipientStatus from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting, timestamp, update_deep, parse_address_list class EmailBackend(AnymailRequestsBackend): """ SendGrid v3 API Email Backend """ esp_name = "SendGrid" def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name # Warn if v2-only username or password settings found username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) if username or password: raise AnymailConfigurationError( "SendGrid v3 API doesn't support username/password auth; Please change to API key.\n" "(For legacy v2 API, use anymail.backends.sendgrid_v2.EmailBackend.)") self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name, kwargs=kwargs, default=True) self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name, kwargs=kwargs, default=None) # Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below). # If/when SendGrid fixes their API, recipient names will end up with extra double quotes # until Anymail is updated to remove the workaround. In the meantime, you can disable it # by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings. self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name, kwargs=kwargs, default=True) # This is SendGrid's newer Web API v3 api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, default="https://api.sendgrid.com/v3/") if not api_url.endswith("/"): api_url += "/" super(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SendGridPayload(message, defaults, self) def raise_for_status(self, response, payload, message): if response.status_code < 200 or response.status_code >= 300: raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) def parse_recipient_status(self, response, payload, message): # If we get here, the send call was successful. # (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.) # SendGrid v3 doesn't provide any information in the response for a successful send, # so simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=payload.message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} class SendGridPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status self.generate_message_id = backend.generate_message_id self.workaround_name_quote_bug = backend.workaround_name_quote_bug self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers self.merge_field_format = backend.merge_field_format self.merge_data = None # late-bound per-recipient data self.merge_global_data = None http_headers = kwargs.pop('headers', {}) http_headers['Authorization'] = 'Bearer %s' % backend.api_key http_headers['Content-Type'] = 'application/json' http_headers['Accept'] = 'application/json' super(SendGridPayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "mail/send" def init_payload(self): self.data = { # becomes json "personalizations": [{}], "headers": CaseInsensitiveDict(), } def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.generate_message_id: self.ensure_message_id() self.build_merge_data() headers = self.data["headers"] if "Reply-To" in headers: # Reply-To must be in its own param reply_to = headers.pop('Reply-To') self.set_reply_to(parse_address_list([reply_to])) if len(headers) > 0: self.data["headers"] = dict(headers) # flatten to normal dict for json serialization else: del self.data["headers"] # don't send empty headers return self.serialize_json(self.data) def ensure_message_id(self): """Ensure message has a known Message-ID for later event tracking""" if "Message-ID" not in self.data["headers"]: # Only make our own if caller hasn't already provided one self.data["headers"]["Message-ID"] = self.make_message_id() self.message_id = self.data["headers"]["Message-ID"] # Workaround for missing message ID (smtp-id) in SendGrid engagement events # (click and open tracking): because unique_args get merged into the raw event # record, we can supply the 'smtp-id' field for any events missing it. self.data.setdefault("custom_args", {})["smtp-id"] = self.message_id def make_message_id(self): """Returns a Message-ID that could be used for this payload Tries to use the from_email's domain as the Message-ID's domain """ try: _, domain = self.data["from"]["email"].split("@") except (AttributeError, KeyError, TypeError, ValueError): domain = None return make_msgid(domain=domain) def build_merge_data(self): """Set personalizations[...]['substitutions'] and data['sections']""" merge_field_format = self.merge_field_format or '{}' if self.merge_data is not None: # Burst apart each to-email in personalizations[0] into a separate # personalization, and add merge_data for that recipient assert len(self.data["personalizations"]) == 1 base_personalizations = self.data["personalizations"].pop() to_list = base_personalizations.pop("to") # {email, name?} for each message.to all_fields = set() for recipient in to_list: personalization = base_personalizations.copy() # captures cc, bcc, and any esp_extra personalization["to"] = [recipient] try: recipient_data = self.merge_data[recipient["email"]] personalization["substitutions"] = {merge_field_format.format(field): data for field, data in recipient_data.items()} all_fields = all_fields.union(recipient_data.keys()) except KeyError: pass # no merge_data for this recipient self.data["personalizations"].append(personalization) if self.merge_field_format is None and all(field.isalnum() for field in all_fields): warnings.warn( "Your SendGrid merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", AnymailWarning) if self.merge_global_data is not None: # (merge into any existing 'sections' from esp_extra) self.data.setdefault("sections", {}).update({ merge_field_format.format(field): data for field, data in self.merge_global_data.items() }) # Confusingly, "Section tags have to be contained within a Substitution tag" # (https://sendgrid.com/docs/API_Reference/SMTP_API/section_tags.html), # so we need to insert a "-field-": "-field-" identity fallback for each # missing global field in the recipient substitutions... global_fields = [merge_field_format.format(field) for field in self.merge_global_data.keys()] for personalization in self.data["personalizations"]: substitutions = personalization.setdefault("substitutions", {}) substitutions.update({field: field for field in global_fields if field not in substitutions}) if (self.merge_field_format is None and all(field.isalnum() for field in self.merge_global_data.keys())): warnings.warn( "Your SendGrid global merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", AnymailWarning) # # Payload construction # @staticmethod def email_object(email, workaround_name_quote_bug=False): """Converts EmailAddress to SendGrid API {email, name} dict""" obj = {"email": email.addr_spec} if email.display_name: # Work around SendGrid API bug: v3 fails to properly quote display-names # containing commas or semicolons in personalizations (but not in from_email # or reply_to). See https://github.com/sendgrid/sendgrid-python/issues/291. # We can work around the problem by quoting the name for SendGrid. if workaround_name_quote_bug: obj["name"] = '"%s"' % rfc822_quote(email.display_name) else: obj["name"] = email.display_name return obj def set_from_email(self, email): self.data["from"] = self.email_object(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: workaround_name_quote_bug = self.workaround_name_quote_bug # Normally, exactly one "personalizations" entry for all recipients # (Exception: with merge_data; will be burst apart later.) self.data["personalizations"][0][recipient_type] = \ [self.email_object(email, workaround_name_quote_bug) for email in emails] self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): if subject != "": # see note in set_text_body about template rendering self.data["subject"] = subject def set_reply_to(self, emails): # SendGrid only supports a single address in the reply_to API param. if len(emails) > 1: self.unsupported_feature("multiple reply_to addresses") if len(emails) > 0: self.data["reply_to"] = self.email_object(emails[0]) def set_extra_headers(self, headers): # SendGrid requires header values to be strings -- not integers. # We'll stringify ints and floats; anything else is the caller's responsibility. self.data["headers"].update({ k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v for k, v in headers.items() }) def set_text_body(self, body): # Empty strings (the EmailMessage default) can cause unexpected SendGrid # template rendering behavior, such as ignoring the HTML template and # rendering HTML from the plaintext template instead. # Treat an empty string as a request to omit the body # (which means use the template content if present.) if body != "": self.data.setdefault("content", []).append({ "type": "text/plain", "value": body, }) def set_html_body(self, body): # SendGrid's API permits multiple html bodies # "If you choose to include the text/plain or text/html mime types, they must be # the first indices of the content array in the order text/plain, text/html." if body != "": # see note in set_text_body about template rendering self.data.setdefault("content", []).append({ "type": "text/html", "value": body, }) def add_alternative(self, content, mimetype): # SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API self.data.setdefault("content", []).append({ "type": mimetype, "value": content, }) def add_attachment(self, attachment): att = { "content": attachment.b64content, "type": attachment.mimetype, "filename": attachment.name or '', # required -- submit empty string if unknown } if attachment.inline: att["disposition"] = "inline" att["content_id"] = attachment.cid self.data.setdefault("attachments", []).append(att) def set_metadata(self, metadata): # SendGrid requires custom_args values to be strings -- not integers. # (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null} # if they're not.) # We'll stringify ints and floats; anything else is the caller's responsibility. self.data["custom_args"] = { k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v for k, v in metadata.items() } def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; SendGrid expects unix timestamp self.data["send_at"] = int(timestamp(send_at)) # strip microseconds def set_tags(self, tags): self.data["categories"] = tags def set_track_clicks(self, track_clicks): self.data.setdefault("tracking_settings", {})["click_tracking"] = { "enable": track_clicks, } def set_track_opens(self, track_opens): # SendGrid's open_tracking setting also supports a "substitution_tag" parameter, # which Anymail doesn't offer directly. (You could add it through esp_extra.) self.data.setdefault("tracking_settings", {})["open_tracking"] = { "enable": track_opens, } def set_template_id(self, template_id): self.data["template_id"] = template_id def set_merge_data(self, merge_data): # Becomes personalizations[...]['substitutions'] in build_merge_data, # after we know recipients and merge_field_format. self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # Becomes data['section'] in build_merge_data, after we know merge_field_format. self.merge_global_data = merge_global_data def set_esp_extra(self, extra): self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format) if "x-smtpapi" in extra: raise AnymailConfigurationError( "You are attempting to use SendGrid v2 API-style x-smtpapi params " "with the SendGrid v3 API. Please update your `esp_extra` to the new API, " "or use 'anymail.backends.sendgrid_v2.EmailBackend' for the old API." ) update_deep(self.data, extra) django-anymail-1.4/anymail/backends/sendgrid_v2.py0000644000076500000240000003502513236203646023205 0ustar medmundsstaff00000000000000import warnings from django.core.mail import make_msgid from requests.structures import CaseInsensitiveDict from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning from ..message import AnymailRecipientStatus from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting, timestamp from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ SendGrid v2 API Email Backend (deprecated) """ esp_name = "SendGrid" def __init__(self, **kwargs): """Init options from Django settings""" # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD 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('generate_message_id', esp_name=esp_name, kwargs=kwargs, default=True) self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name, kwargs=kwargs, default=None) # This is SendGrid's older Web API v2 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(EmailBackend, self).__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SendGridPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) try: sendgrid_message = parsed_response["message"] except (KeyError, TypeError): raise AnymailRequestsAPIError("Invalid SendGrid API response format", email_message=message, payload=payload, response=response, backend=self) if sendgrid_message != "success": errors = parsed_response.get("errors", []) raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors), email_message=message, payload=payload, response=response, backend=self) # Simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=payload.message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} class SendGridPayload(RequestsPayload): """ SendGrid v2 API Mail Send payload """ def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status self.generate_message_id = backend.generate_message_id self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers self.smtpapi = {} # SendGrid x-smtpapi field self.to_list = [] # needed for build_merge_data self.merge_field_format = backend.merge_field_format self.merge_data = None # late-bound per-recipient data self.merge_global_data = None http_headers = kwargs.pop('headers', {}) query_params = kwargs.pop('params', {}) if backend.api_key is not None: http_headers['Authorization'] = 'Bearer %s' % backend.api_key else: query_params['api_user'] = backend.username query_params['api_key'] = backend.password super(SendGridPayload, self).__init__(message, defaults, backend, params=query_params, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "mail.send.json" def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.generate_message_id: self.ensure_message_id() self.build_merge_data() if self.merge_data is not None: # Move the 'to' recipients to smtpapi, so SG does batch send # (else all recipients would see each other's emails). # Regular 'to' must still be a valid email (even though "ignored")... # we use the from_email as recommended by SG support # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250) self.smtpapi['to'] = [email.address for email in self.to_list] self.data['to'] = [self.data['from']] self.data['toname'] = [self.data.get('fromname', " ")] # Serialize x-smtpapi to json: if len(self.smtpapi) > 0: # If esp_extra was also used to set x-smtpapi, need to merge it if "x-smtpapi" in self.data: esp_extra_smtpapi = self.data["x-smtpapi"] for key, value in esp_extra_smtpapi.items(): if key == "filters": # merge filters (else it's difficult to mix esp_extra with other features) self.smtpapi.setdefault(key, {}).update(value) else: # all other keys replace any current value self.smtpapi[key] = value self.data["x-smtpapi"] = self.serialize_json(self.smtpapi) elif "x-smtpapi" in self.data: self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"]) # Serialize extra headers to json: headers = self.data["headers"] self.data["headers"] = self.serialize_json(dict(headers.items())) return self.data def ensure_message_id(self): """Ensure message has a known Message-ID for later event tracking""" headers = self.data["headers"] if "Message-ID" not in headers: # Only make our own if caller hasn't already provided one headers["Message-ID"] = self.make_message_id() self.message_id = headers["Message-ID"] # Workaround for missing message ID (smtp-id) in SendGrid engagement events # (click and open tracking): because unique_args get merged into the raw event # record, we can supply the 'smtp-id' field for any events missing it. self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id def make_message_id(self): """Returns a Message-ID that could be used for this payload Tries to use the from_email's domain as the Message-ID's domain """ try: _, domain = self.data["from"].split("@") except (AttributeError, KeyError, TypeError, ValueError): domain = None return make_msgid(domain=domain) def build_merge_data(self): """Set smtpapi['sub'] and ['section']""" if self.merge_data is not None: # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format) # to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...}) all_fields = set() for recipient_data in self.merge_data.values(): all_fields = all_fields.union(recipient_data.keys()) recipients = [email.addr_spec for email in self.to_list] if self.merge_field_format is None and all(field.isalnum() for field in all_fields): warnings.warn( "Your SendGrid merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", AnymailWarning) sub_field_fmt = self.merge_field_format or '{}' sub_fields = {field: sub_field_fmt.format(field) for field in all_fields} self.smtpapi['sub'] = { # If field data is missing for recipient, use (formatted) field as the substitution. # (This allows default to resolve from global "section" substitutions.) sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field]) for recipient in recipients] for field in all_fields } if self.merge_global_data is not None: section_field_fmt = self.merge_field_format or '{}' self.smtpapi['section'] = { section_field_fmt.format(field): data for field, data in self.merge_global_data.items() } # # Payload construction # def init_payload(self): self.data = {} # {field: [multiple, values]} self.files = {} self.data['headers'] = CaseInsensitiveDict() # headers keys are case-insensitive def set_from_email(self, email): self.data["from"] = email.addr_spec if email.display_name: self.data["fromname"] = email.display_name def set_to(self, emails): self.to_list = emails # track for later use by build_merge_data self.set_recipients('to', emails) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [email.addr_spec for email in emails] empty_name = " " # SendGrid API balks on complete empty name fields self.data[recipient_type + "name"] = [email.display_name or empty_name for email in emails] self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails): # Note: SendGrid mangles the 'replyto' API param: it drops # all but the last email in a multi-address replyto, and # drops all the display names. [tested 2016-03-10] # # To avoid those quirks, we provide a fully-formed Reply-To # in the custom headers, which makes it through intact. if emails: reply_to = ", ".join([email.address for email in emails]) self.data["headers"]["Reply-To"] = reply_to def set_extra_headers(self, headers): # SendGrid requires header values to be strings -- not integers. # We'll stringify ints and floats; anything else is the caller's responsibility. # (This field gets converted to json in self.serialize_data) self.data["headers"].update({ k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v for k, v in headers.items() }) def set_text_body(self, body): self.data["text"] = body def set_html_body(self, body): if "html" in self.data: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body def add_attachment(self, attachment): filename = attachment.name or "" if attachment.inline: filename = filename or attachment.cid # must have non-empty name for the cid matching content_field = "content[%s]" % filename self.data[content_field] = attachment.cid files_field = "files[%s]" % filename if files_field in self.files: # It's possible SendGrid could actually handle this case (needs testing), # but requests doesn't seem to accept a list of tuples for a files field. # (See the Mailgun EmailBackend version for a different approach that might work.) self.unsupported_feature( "multiple attachments with the same filename ('%s')" % filename if filename else "multiple unnamed attachments") self.files[files_field] = (filename, attachment.content, attachment.mimetype) def set_metadata(self, metadata): self.smtpapi['unique_args'] = metadata def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; SendGrid expects unix timestamp self.smtpapi["send_at"] = int(timestamp(send_at)) # strip microseconds def set_tags(self, tags): self.smtpapi["category"] = tags def add_filter(self, filter_name, setting, val): self.smtpapi.setdefault('filters', {})\ .setdefault(filter_name, {})\ .setdefault('settings', {})[setting] = val def set_track_clicks(self, track_clicks): self.add_filter('clicktrack', 'enable', int(track_clicks)) def set_track_opens(self, track_opens): # SendGrid's opentrack filter also supports a "replace" # parameter, which Anymail doesn't offer directly. # (You could add it through esp_extra.) self.add_filter('opentrack', 'enable', int(track_opens)) def set_template_id(self, template_id): self.add_filter('templates', 'enable', 1) self.add_filter('templates', 'template_id', template_id) # Must ensure text and html are non-empty, or template parts won't render. # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates if not self.data.get("text", ""): self.data["text"] = " " if not self.data.get("html", ""): self.data["html"] = " " def set_merge_data(self, merge_data): # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format. self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format. self.merge_global_data = merge_global_data def set_esp_extra(self, extra): self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format) self.data.update(extra) django-anymail-1.4/anymail/backends/sparkpost.py0000644000076500000240000002017413236203646023024 0ustar medmundsstaff00000000000000from __future__ import absolute_import # we want the sparkpost package, not our own module from .base import AnymailBaseBackend, BasePayload from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting try: from sparkpost import SparkPost, SparkPostException except ImportError: raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') class EmailBackend(AnymailBaseBackend): """ SparkPost Email Backend (using python-sparkpost client) """ esp_name = "SparkPost" def __init__(self, **kwargs): """Init options from Django settings""" super(EmailBackend, self).__init__(**kwargs) # SPARKPOST_API_KEY is optional - library reads from env by default self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True, default=None) try: self.sp = SparkPost(self.api_key) # SparkPost API instance except SparkPostException as err: # This is almost certainly a missing API key raise AnymailConfigurationError( "Error initializing SparkPost: %s\n" "You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} " "or ANYMAIL_SPARKPOST_API_KEY in your Django settings, " "or SPARKPOST_API_KEY in your environment." % str(err) ) # Note: SparkPost python API doesn't expose requests session sharing # (so there's no need to implement open/close connection management here) def build_message_payload(self, message, defaults): return SparkPostPayload(message, defaults, self) def post_to_esp(self, payload, message): params = payload.get_api_params() try: response = self.sp.transmissions.send(**params) except SparkPostException as err: raise AnymailAPIError( str(err), backend=self, email_message=message, payload=payload, response=getattr(err, 'response', None), # SparkPostAPIException requests.Response status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code ) return response def parse_recipient_status(self, response, payload, message): try: accepted = response['total_accepted_recipients'] rejected = response['total_rejected_recipients'] transmission_id = response['id'] except (KeyError, TypeError) as err: raise AnymailAPIError( "%s in SparkPost.transmissions.send result %r" % (str(err), response), backend=self, email_message=message, payload=payload, ) # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # (* looks like undocumented 'rcpt_to_errors' might provide this info.) # If all are one or the other, we can report a specific status; # else just report 'unknown' for all recipients. recipient_count = len(payload.all_recipients) if accepted == recipient_count and rejected == 0: status = 'queued' elif rejected == recipient_count and accepted == 0: status = 'rejected' else: # mixed results, or wrong total status = 'unknown' recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status) return {recipient.addr_spec: recipient_status for recipient in payload.all_recipients} class SparkPostPayload(BasePayload): def init_payload(self): self.params = {} self.all_recipients = [] self.to_emails = [] self.merge_data = {} def get_api_params(self): # Compose recipients param from to_emails and merge_data (if any) recipients = [] if len(self.merge_data) > 0: # Build JSON recipient structures for email in self.to_emails: rcpt = {'address': {'email': email.addr_spec}} if email.display_name: rcpt['address']['name'] = email.display_name try: rcpt['substitution_data'] = self.merge_data[email.addr_spec] except KeyError: pass # no merge_data or none for this recipient recipients.append(rcpt) else: # Just use simple recipients list recipients = [email.address for email in self.to_emails] if recipients: self.params['recipients'] = recipients # Must remove empty string "content" params when using stored template if self.params.get('template', None): for content_param in ['subject', 'text', 'html']: try: if not self.params[content_param]: del self.params[content_param] except KeyError: pass return self.params def set_from_email_list(self, emails): # SparkPost supports multiple From email addresses, # as a single comma-separated string self.params['from_email'] = ", ".join([email.address for email in emails]) def set_to(self, emails): if emails: self.to_emails = emails # bound to params['recipients'] in get_api_params self.all_recipients += emails def set_cc(self, emails): if emails: self.params['cc'] = [email.address for email in emails] self.all_recipients += emails def set_bcc(self, emails): if emails: self.params['bcc'] = [email.address for email in emails] self.all_recipients += emails def set_subject(self, subject): self.params['subject'] = subject def set_reply_to(self, emails): if emails: # reply_to is only documented as a single email, but this seems to work: self.params['reply_to'] = ', '.join([email.address for email in emails]) def set_extra_headers(self, headers): if headers: self.params['custom_headers'] = headers def set_text_body(self, body): self.params['text'] = body def set_html_body(self, body): if 'html' in self.params: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") self.params['html'] = body def add_attachment(self, attachment): if attachment.inline: param = 'inline_images' name = attachment.cid else: param = 'attachments' name = attachment.name or '' self.params.setdefault(param, []).append({ 'type': attachment.mimetype, 'name': name, 'data': attachment.b64content}) # Anymail-specific payload construction def set_metadata(self, metadata): self.params['metadata'] = metadata def set_send_at(self, send_at): try: self.params['start_time'] = send_at.replace(microsecond=0).isoformat() except (AttributeError, TypeError): self.params['start_time'] = send_at # assume user already formatted def set_tags(self, tags): if len(tags) > 0: self.params['campaign'] = tags[0] if len(tags) > 1: self.unsupported_feature('multiple tags (%r)' % tags) def set_track_clicks(self, track_clicks): self.params['track_clicks'] = track_clicks def set_track_opens(self, track_opens): self.params['track_opens'] = track_opens def set_template_id(self, template_id): # 'template' transmissions.send param becomes 'template_id' in API json 'content' self.params['template'] = template_id def set_merge_data(self, merge_data): self.merge_data = merge_data # merged into params['recipients'] in get_api_params def set_merge_global_data(self, merge_global_data): self.params['substitution_data'] = merge_global_data # ESP-specific payload construction def set_esp_extra(self, extra): self.params.update(extra) django-anymail-1.4/anymail/backends/test.py0000644000076500000240000001217613236203646021760 0ustar medmundsstaff00000000000000from django.core import mail from .base import AnymailBaseBackend, BasePayload from ..exceptions import AnymailAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting class EmailBackend(AnymailBaseBackend): """ Anymail backend that simulates sending messages, useful for testing. Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend). In addition: * Anymail send params parsed from the message will be attached to the outbox message as a dict in the attr `anymail_test_params` * If the caller supplies an `anymail_test_response` attr on the message, that will be used instead of the default "sent" response. It can be either an AnymailRecipientStatus or an instance of AnymailAPIError (or a subclass) to raise an exception. """ esp_name = "Test" def __init__(self, *args, **kwargs): super(EmailBackend, self).__init__(*args, **kwargs) if not hasattr(mail, 'outbox'): mail.outbox = [] # see django.core.mail.backends.locmem def get_esp_message_id(self, message): # Get a unique ID for the message. The message must have been added to # the outbox first. return mail.outbox.index(message) def build_message_payload(self, message, defaults): return TestPayload(backend=self, message=message, defaults=defaults) def post_to_esp(self, payload, message): # Keep track of the sent messages and params (for test cases) message.anymail_test_params = payload.params mail.outbox.append(message) try: # Tests can supply their own message.test_response: response = message.anymail_test_response if isinstance(response, AnymailAPIError): raise response except AttributeError: # Default is to return 'sent' for each recipient status = AnymailRecipientStatus( message_id=self.get_esp_message_id(message), status='sent' ) response = { 'recipient_status': {email: status for email in payload.recipient_emails} } return response def parse_recipient_status(self, response, payload, message): try: return response['recipient_status'] except KeyError: raise AnymailAPIError('Unparsable test response') class TestPayload(BasePayload): # For test purposes, just keep a dict of the params we've received. # (This approach is also useful for native API backends -- think of # payload.params as collecting kwargs for esp_native_api.send().) def init_payload(self): self.params = {} self.recipient_emails = [] def set_from_email(self, email): self.params['from'] = email def set_to(self, emails): self.params['to'] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_cc(self, emails): self.params['cc'] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_bcc(self, emails): self.params['bcc'] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_subject(self, subject): self.params['subject'] = subject def set_reply_to(self, emails): self.params['reply_to'] = emails def set_extra_headers(self, headers): self.params['extra_headers'] = headers def set_text_body(self, body): self.params['text_body'] = body def set_html_body(self, body): self.params['html_body'] = body def add_alternative(self, content, mimetype): self.unsupported_feature("alternative part with type '%s'" % mimetype) def add_attachment(self, attachment): self.params.setdefault('attachments', []).append(attachment) def set_metadata(self, metadata): self.params['metadata'] = metadata def set_send_at(self, send_at): self.params['send_at'] = send_at def set_tags(self, tags): self.params['tags'] = tags def set_track_clicks(self, track_clicks): self.params['track_clicks'] = track_clicks def set_track_opens(self, track_opens): self.params['track_opens'] = track_opens def set_template_id(self, template_id): self.params['template_id'] = template_id def set_merge_data(self, merge_data): self.params['merge_data'] = merge_data def set_merge_global_data(self, merge_global_data): self.params['merge_global_data'] = merge_global_data def set_esp_extra(self, extra): # Merge extra into params self.params.update(extra) class _EmailBackendWithRequiredSetting(EmailBackend): """Test backend with a required setting `sample_setting`. Intended only for internal use by Anymail settings tests. """ def __init__(self, *args, **kwargs): esp_name = self.esp_name self.sample_setting = get_anymail_setting('sample_setting', esp_name=esp_name, kwargs=kwargs, allow_bare=True) super(_EmailBackendWithRequiredSetting, self).__init__(*args, **kwargs) django-anymail-1.4/anymail/checks.py0000644000076500000240000000175513236646215020473 0ustar medmundsstaff00000000000000from django.conf import settings from django.core import checks def check_deprecated_settings(app_configs, **kwargs): errors = [] anymail_settings = getattr(settings, "ANYMAIL", {}) # anymail.W001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET if "WEBHOOK_AUTHORIZATION" in anymail_settings: errors.append(checks.Warning( "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.", hint="You must update your settings.py. The old name will stop working in a near-future release.", id="anymail.W001", )) if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"): errors.append(checks.Warning( "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.", hint="You must update your settings.py. The old name will stop working in a near-future release.", id="anymail.W001", )) return errors django-anymail-1.4/anymail/exceptions.py0000644000076500000240000001651613236203646021412 0ustar medmundsstaff00000000000000import json from traceback import format_exception_only import six from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from requests import HTTPError class AnymailError(Exception): """Base class for exceptions raised by Anymail Overrides __str__ to provide additional information about the ESP API call and response. """ def __init__(self, *args, **kwargs): """ Optional kwargs: email_message: the original EmailMessage being sent status_code: HTTP status code of response to ESP send call backend: the backend instance involved payload: data arg (*not* json-stringified) for the ESP send call response: requests.Response from the send call raised_from: original/wrapped Exception esp_name: what to call the ESP (read from backend if provided) """ self.backend = kwargs.pop('backend', None) self.email_message = kwargs.pop('email_message', None) self.payload = kwargs.pop('payload', None) self.status_code = kwargs.pop('status_code', None) self.raised_from = kwargs.pop('raised_from', None) self.esp_name = kwargs.pop('esp_name', self.backend.esp_name if self.backend else None) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError self.response = kwargs.get('response', None) else: self.response = kwargs.pop('response', None) super(AnymailError, self).__init__(*args, **kwargs) def __str__(self): parts = [ " ".join([str(arg) for arg in self.args]), self.describe_raised_from(), self.describe_send(), self.describe_response(), ] return "\n".join(filter(None, parts)) def describe_send(self): """Return a string describing the ESP send in self.email_message, or None""" if self.email_message is None: return None description = "Sending a message" try: description += " to %s" % ','.join(self.email_message.to) except AttributeError: pass try: description += " from %s" % self.email_message.from_email except AttributeError: pass return description def describe_response(self): """Return a formatted string of self.status_code and response, or None""" if self.status_code is None: return None # Decode response.reason to text -- borrowed from requests.Response.raise_for_status: reason = self.response.reason if isinstance(reason, six.binary_type): try: reason = reason.decode('utf-8') except UnicodeDecodeError: reason = reason.decode('iso-8859-1') description = "%s API response %d: %s" % (self.esp_name or "ESP", self.status_code, reason) try: json_response = self.response.json() description += "\n" + json.dumps(json_response, indent=2) except (AttributeError, KeyError, ValueError): # not JSON = ValueError try: description += " " + self.response.text except AttributeError: pass return description def describe_raised_from(self): """Return the original exception""" if self.raised_from is None: return None return ''.join(format_exception_only(type(self.raised_from), self.raised_from)).strip() class AnymailAPIError(AnymailError): """Exception for unsuccessful response from ESP's API.""" class AnymailRequestsAPIError(AnymailAPIError, HTTPError): """Exception for unsuccessful response from a requests API.""" def __init__(self, *args, **kwargs): super(AnymailRequestsAPIError, self).__init__(*args, **kwargs) if self.response is not None: self.status_code = self.response.status_code class AnymailRecipientsRefused(AnymailError): """Exception for send where all recipients are invalid or rejected.""" def __init__(self, message=None, *args, **kwargs): if message is None: message = "All message recipients were rejected or invalid" super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs) class AnymailInvalidAddress(AnymailError, ValueError): """Exception when using an invalidly-formatted email address""" class AnymailUnsupportedFeature(AnymailError, ValueError): """Exception for Anymail features that the ESP doesn't support. This is typically raised when attempting to send a Django EmailMessage that uses options or values you might expect to work, but that are silently ignored by or can't be communicated to the ESP's API. It's generally *not* raised for ESP-specific limitations, like the number of tags allowed on a message. (Anymail expects the ESP to return an API error for these where appropriate, and tries to avoid duplicating each ESP's validation logic locally.) """ class AnymailSerializationError(AnymailError, TypeError): """Exception for data that Anymail can't serialize for the ESP's API. This typically results from including something like a date or Decimal in your merge_vars. """ # inherits from TypeError for compatibility with JSON serialization error def __init__(self, message=None, orig_err=None, *args, **kwargs): if message is None: # self.esp_name not set until super init, so duplicate logic to get esp_name backend = kwargs.get('backend', None) esp_name = kwargs.get('esp_name', backend.esp_name if backend else "the ESP") message = "Don't know how to send this data to %s. " \ "Try converting it to a string or number first." % esp_name if orig_err is not None: message += "\n%s" % str(orig_err) super(AnymailSerializationError, self).__init__(message, *args, **kwargs) class AnymailCancelSend(AnymailError): """Pre-send signal receiver can raise to prevent message send""" class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation): """Exception when a webhook cannot be validated. Django's SuspiciousOperation turns into an HTTP 400 error in production. """ 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]` " \ "with your desired backends)" % (missing_package, backend) super(AnymailImproperlyInstalled, self).__init__(message) # Warnings class AnymailWarning(Warning): """Base warning for Anymail""" class AnymailInsecureWebhookWarning(AnymailWarning): """Warns when webhook configured without any validation""" class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning): """Warning for deprecated Anymail features""" django-anymail-1.4/anymail/inbound.py0000644000076500000240000003524013236203646020662 0ustar medmundsstaff00000000000000from base64 import b64decode from email import message_from_string from email.message import Message from email.utils import unquote import six from django.core.files.uploadedfile import SimpleUploadedFile from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date # Python 2/3.*-compatible email.parser.HeaderParser(policy=email.policy.default) try: # With Python 3.3+ (email6) package, can use HeaderParser with default policy from email.parser import HeaderParser from email.policy import default as accurate_header_unfolding_policy # vs. compat32 except ImportError: # Earlier Pythons don't have HeaderParser, and/or try preserve earlier compatibility bugs # by failing to properly unfold headers (see RFC 5322 section 2.2.3) from email.parser import Parser import re accurate_header_unfolding_policy = object() class HeaderParser(Parser, object): def __init__(self, _class, policy=None): # This "backport" doesn't actually support policies, but we want to ensure # that callers aren't trying to use HeaderParser's default compat32 policy # (which doesn't properly unfold headers) assert policy is accurate_header_unfolding_policy super(HeaderParser, self).__init__(_class) def parsestr(self, text, headersonly=True): unfolded = self._unfold_headers(text) return super(HeaderParser, self).parsestr(unfolded, headersonly=True) @staticmethod def _unfold_headers(text): # "Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP" # (WSP is space or tab, and per email.parser semantics, we allow CRLF, CR, or LF endings) return re.sub(r'(\r\n|\r|\n)(?=[ \t])', "", text) class AnymailInboundMessage(Message, object): # `object` ensures new-style class in Python 2) """ A normalized, parsed inbound email message. A subclass of email.message.Message, with some additional convenience properties, plus helpful methods backported from Python 3.6+ email.message.EmailMessage (or really, MIMEPart) """ # Why Python email.message.Message rather than django.core.mail.EmailMessage? # Django's EmailMessage is really intended for constructing a (limited subset of) # Message to send; Message is better designed for representing arbitrary messages: # # * Message is easily parsed from raw mime (which is an inbound format provided # by many ESPs), and can accurately represent any mime email that might be received # * Message can represent repeated header fields (e.g., "Received") which # are common in inbound messages # * Django's EmailMessage defaults a bunch of properties in ways that aren't helpful # (e.g., from_email from settings) def __init__(self, *args, **kwargs): # Note: this must accept zero arguments, for use with message_from_string (email.parser) super(AnymailInboundMessage, self).__init__(*args, **kwargs) # Additional attrs provided by some ESPs: self.envelope_sender = None self.envelope_recipient = None self.stripped_text = None self.stripped_html = None self.spam_detected = None self.spam_score = None # # Convenience accessors # @property def from_email(self): """EmailAddress """ # equivalent to Python 3.2+ message['From'].addresses[0] from_email = self.get_address_header('From') if len(from_email) == 1: return from_email[0] elif len(from_email) == 0: return None else: return from_email # unusual, but technically-legal multiple-From; preserve list @property def to(self): """list of EmailAddress objects from To header""" # equivalent to Python 3.2+ message['To'].addresses return self.get_address_header('To') @property def cc(self): """list of EmailAddress objects from Cc header""" # equivalent to Python 3.2+ message['Cc'].addresses return self.get_address_header('Cc') @property def subject(self): """str value of Subject header, or None""" return self['Subject'] @property def date(self): """datetime.datetime from Date header, or None if missing/invalid""" # equivalent to Python 3.2+ message['Date'].datetime return self.get_date_header('Date') @property def text(self): """Contents of the (first) text/plain body part, or None""" return self._get_body_content('text/plain') @property def html(self): """Contents of the (first) text/html body part, or None""" return self._get_body_content('text/html') @property def attachments(self): """list of attachments (as MIMEPart objects); excludes inlines""" return [part for part in self.walk() if part.is_attachment()] @property def inline_attachments(self): """dict of Content-ID: attachment (as MIMEPart objects)""" return {unquote(part['Content-ID']): part for part in self.walk() if part.is_inline_attachment() and part['Content-ID']} def get_address_header(self, header): """Return the value of header parsed into a (possibly-empty) list of EmailAddress objects""" values = self.get_all(header) if values is not None: values = parse_address_list(values) return values or [] def get_date_header(self, header): """Return the value of header parsed into a datetime.date, or None""" value = self[header] if value is not None: value = parse_rfc2822date(value) return value def _get_body_content(self, content_type): # This doesn't handle as many corner cases as Python 3.6 email.message.EmailMessage.get_body, # but should work correctly for nearly all real-world inbound messages. # We're guaranteed to have `is_attachment` available, because all AnymailInboundMessage parts # should themselves be AnymailInboundMessage. for part in self.walk(): if part.get_content_type() == content_type and not part.is_attachment(): payload = part.get_payload(decode=True) if payload is not None: return payload.decode('utf-8') return None # Backport from Python 3.5 email.message.Message def get_content_disposition(self): try: return super(AnymailInboundMessage, self).get_content_disposition() except AttributeError: return get_content_disposition(self) # Backport from Python 3.4.2 email.message.MIMEPart def is_attachment(self): return self.get_content_disposition() == 'attachment' # New for Anymail def is_inline_attachment(self): return self.get_content_disposition() == 'inline' def get_content_bytes(self): """Return the raw payload bytes""" maintype = self.get_content_maintype() if maintype == 'message': # The attachment's payload is a single (parsed) email Message; flatten it to bytes. # (Note that self.is_multipart() misleadingly returns True in this case.) payload = self.get_payload() assert len(payload) == 1 # should be exactly one message try: return payload[0].as_bytes() # Python 3 except AttributeError: return payload[0].as_string().encode('utf-8') elif maintype == 'multipart': # The attachment itself is multipart; the payload is a list of parts, # and it's not clear which one is the "content". raise ValueError("get_content_bytes() is not valid on multipart messages " "(perhaps you want as_bytes()?)") return self.get_payload(decode=True) def get_content_text(self, charset='utf-8'): """Return the payload decoded to text""" maintype = self.get_content_maintype() if maintype == 'message': # The attachment's payload is a single (parsed) email Message; flatten it to text. # (Note that self.is_multipart() misleadingly returns True in this case.) payload = self.get_payload() assert len(payload) == 1 # should be exactly one message return payload[0].as_string() elif maintype == 'multipart': # The attachment itself is multipart; the payload is a list of parts, # and it's not clear which one is the "content". raise ValueError("get_content_text() is not valid on multipart messages " "(perhaps you want as_string()?)") return self.get_payload(decode=True).decode(charset) def as_uploaded_file(self): """Return the attachment converted to a Django UploadedFile""" if self['Content-Disposition'] is None: return None # this part is not an attachment name = self.get_filename() content_type = self.get_content_type() content = self.get_content_bytes() return SimpleUploadedFile(name, content, content_type) # # Construction # # These methods are intended primarily for internal Anymail use # (in inbound webhook handlers) @classmethod def parse_raw_mime(cls, s): """Returns a new AnymailInboundMessage parsed from str s""" return message_from_string(s, cls) @classmethod def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None, text=None, text_charset='utf-8', html=None, html_charset='utf-8', attachments=None): """ Returns a new AnymailInboundMessage constructed from params. This is designed to handle the sorts of email fields typically present in ESP parsed inbound messages. (It's not a generalized MIME message constructor.) :param raw_headers: {str|None} base (or complete) message headers as a single string :param from_email: {str|None} value for From header :param to: {str|None} value for To header :param cc: {str|None} value for Cc header :param subject: {str|None} value for Subject header :param headers: {sequence[(str, str)]|mapping|None} additional headers :param text: {str|None} plaintext body :param text_charset: {str} charset of plaintext body; default utf-8 :param html: {str|None} html body :param html_charset: {str} charset of html body; default utf-8 :param attachments: {list[MIMEBase]|None} as returned by construct_attachment :return: {AnymailInboundMessage} """ if raw_headers is not None: msg = HeaderParser(cls, policy=accurate_header_unfolding_policy).parsestr(raw_headers) msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later else: msg = cls() if from_email is not None: del msg['From'] # override raw_headers value, if any msg['From'] = from_email if to is not None: del msg['To'] msg['To'] = to if cc is not None: del msg['Cc'] msg['Cc'] = cc if subject is not None: del msg['Subject'] msg['Subject'] = subject if headers is not None: try: header_items = headers.items() # mapping except AttributeError: header_items = headers # sequence of (key, value) for name, value in header_items: msg.add_header(name, value) # For simplicity, we always build a MIME structure that could support plaintext/html # alternative bodies, inline attachments for the body(ies), and message attachments. # This may be overkill for simpler messages, but the structure is never incorrect. del msg['MIME-Version'] # override raw_headers values, if any del msg['Content-Type'] msg['MIME-Version'] = '1.0' msg['Content-Type'] = 'multipart/mixed' related = cls() # container for alternative bodies and inline attachments related['Content-Type'] = 'multipart/related' msg.attach(related) alternatives = cls() # container for text and html bodies alternatives['Content-Type'] = 'multipart/alternative' related.attach(alternatives) if text is not None: part = cls() part['Content-Type'] = 'text/plain' part.set_payload(text, charset=text_charset) alternatives.attach(part) if html is not None: part = cls() part['Content-Type'] = 'text/html' part.set_payload(html, charset=html_charset) alternatives.attach(part) if attachments is not None: for attachment in attachments: if attachment.is_inline_attachment(): related.attach(attachment) else: msg.attach(attachment) return msg @classmethod def construct_attachment_from_uploaded_file(cls, file, content_id=None): # This pulls the entire file into memory; it would be better to implement # some sort of lazy attachment where the content is only pulled in if/when # requested (and then use file.chunks() to minimize memory usage) return cls.construct_attachment( content_type=file.content_type, content=file.read(), filename=file.name, content_id=content_id, charset=file.charset) @classmethod def construct_attachment(cls, content_type, content, charset=None, filename=None, content_id=None, base64=False): part = cls() part['Content-Type'] = content_type part['Content-Disposition'] = 'inline' if content_id is not None else 'attachment' if filename is not None: part.set_param('name', filename, header='Content-Type') part.set_param('filename', filename, header='Content-Disposition') if content_id is not None: part['Content-ID'] = angle_wrap(content_id) if base64: content = b64decode(content) payload = content if part.get_content_maintype() == 'message': # email.Message parses message/rfc822 parts as a "multipart" (list) payload # whose single item is the recursively-parsed message attachment if isinstance(content, six.binary_type): content = content.decode() payload = [cls.parse_raw_mime(content)] charset = None part.set_payload(payload, charset) return part django-anymail-1.4/anymail/message.py0000644000076500000240000001054213171475771020656 0ustar medmundsstaff00000000000000from email.mime.image import MIMEImage from email.utils import unquote import os from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid from .utils import UNSET class AnymailMessageMixin(object): """Mixin for EmailMessage that exposes Anymail features. Use of this mixin is optional. You can always just set Anymail attributes on any EmailMessage. (The mixin can be helpful with type checkers and other development tools that complain about accessing Anymail's added attributes on a regular EmailMessage.) """ def __init__(self, *args, **kwargs): self.esp_extra = kwargs.pop('esp_extra', UNSET) self.metadata = kwargs.pop('metadata', UNSET) self.send_at = kwargs.pop('send_at', UNSET) self.tags = kwargs.pop('tags', UNSET) self.track_clicks = kwargs.pop('track_clicks', UNSET) self.track_opens = kwargs.pop('track_opens', UNSET) self.template_id = kwargs.pop('template_id', UNSET) self.merge_data = kwargs.pop('merge_data', UNSET) self.merge_global_data = kwargs.pop('merge_global_data', UNSET) self.anymail_status = AnymailStatus() # noinspection PyArgumentList super(AnymailMessageMixin, self).__init__(*args, **kwargs) def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None): """Add inline image from file path to an EmailMessage, and return its content id""" assert isinstance(self, EmailMessage) return attach_inline_image_file(self, path, subtype, idstring, domain) def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None): """Add inline image and return its content id""" assert isinstance(self, EmailMessage) return attach_inline_image(self, content, filename, subtype, idstring, domain) class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives): pass def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None): """Add inline image from file path to an EmailMessage, and return its content id""" filename = os.path.basename(path) with open(path, 'rb') as f: content = f.read() return attach_inline_image(message, content, filename, subtype, idstring, domain) def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None): """Add inline image to an EmailMessage, and return its content id""" content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>) image = MIMEImage(content, subtype) image.add_header('Content-Disposition', 'inline', filename=filename) image.add_header('Content-ID', content_id) message.attach(image) return unquote(content_id) # Without <...>, for use as the tag src ANYMAIL_STATUSES = [ 'sent', # the ESP has sent the message (though it may or may not get delivered) 'queued', # the ESP will try to send the message later 'invalid', # the recipient email was not valid 'rejected', # the recipient is blacklisted 'failed', # the attempt to send failed for some other reason 'unknown', # anything else ] class AnymailRecipientStatus(object): """Information about an EmailMessage's send status for a single recipient""" def __init__(self, message_id, status): self.message_id = message_id # ESP message id self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP class AnymailStatus(object): """Information about an EmailMessage's send status for all recipients""" def __init__(self): self.message_id = None # set of ESP message ids across all recipients, or bare id if only one, or None self.status = None # set of ANYMAIL_STATUSES across all recipients, or None for not yet sent to ESP self.recipients = {} # per-recipient: { email: AnymailRecipientStatus, ... } self.esp_response = None def set_recipient_status(self, recipients): self.recipients.update(recipients) recipient_statuses = self.recipients.values() self.message_id = set([recipient.message_id for recipient in recipient_statuses]) if len(self.message_id) == 1: self.message_id = self.message_id.pop() # de-set-ify if single message_id self.status = set([recipient.status for recipient in recipient_statuses]) django-anymail-1.4/anymail/signals.py0000644000076500000240000001077113236203646020666 0ustar medmundsstaff00000000000000from django.dispatch import Signal # Outbound message, before sending pre_send = Signal(providing_args=['message', 'esp_name']) # Outbound message, after sending post_send = Signal(providing_args=['message', 'status', 'esp_name']) # Delivery and tracking events for sent messages tracking = Signal(providing_args=['event', 'esp_name']) # Event for receiving inbound messages inbound = Signal(providing_args=['event', 'esp_name']) class AnymailEvent(object): """Base class for normalized Anymail webhook events""" def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs): self.event_type = event_type # normalized to an EventType str self.timestamp = timestamp # normalized to an aware datetime self.event_id = event_id # opaque str self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict) class AnymailTrackingEvent(AnymailEvent): """Normalized delivery and tracking event for sent messages""" def __init__(self, **kwargs): super(AnymailTrackingEvent, self).__init__(**kwargs) self.click_url = kwargs.pop('click_url', None) # str self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized self.message_id = kwargs.pop('message_id', None) # str, format may vary self.metadata = kwargs.pop('metadata', {}) # dict self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name) self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str self.tags = kwargs.pop('tags', []) # list of str self.user_agent = kwargs.pop('user_agent', None) # str class AnymailInboundEvent(AnymailEvent): """Normalized inbound message event""" def __init__(self, **kwargs): super(AnymailInboundEvent, self).__init__(**kwargs) self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage self.recipient = kwargs.pop('recipient', None) # str: envelope recipient self.sender = kwargs.pop('sender', None) # str: envelope sender self.stripped_text = kwargs.pop('stripped_text', None) # cleaned of quotes/signatures (varies by ESP) self.stripped_html = kwargs.pop('stripped_html', None) self.spam_detected = kwargs.pop('spam_detected', None) # bool self.spam_score = kwargs.pop('spam_score', None) # float: usually SpamAssassin # SPF status? # DKIM status? # DMARC status? (no ESP has documented support yet) class EventType: """Constants for normalized Anymail event types""" # Delivery (and non-delivery) event types: # (these match message.ANYMAIL_STATUSES where appropriate) QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later) SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered) REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email) FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error) BOUNCED = 'bounced' # rejected or blocked by receiving MTA DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED DELIVERED = 'delivered' # accepted by receiving MTA AUTORESPONDED = 'autoresponded' # a bot replied # Tracking event types: OPENED = 'opened' # open tracking CLICKED = 'clicked' # click tracking COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop) UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form # Inbound event types: INBOUND = 'inbound' # received message INBOUND_FAILED = 'inbound_failed' # Other: UNKNOWN = 'unknown' # anything else class RejectReason: """Constants for normalized Anymail reject/drop reasons""" INVALID = 'invalid' # bad address format BOUNCED = 'bounced' # (previous) bounce from recipient TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts BLOCKED = 'blocked' # ESP policy suppression SPAM = 'spam' # (previous) spam complaint from recipient UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient OTHER = 'other' django-anymail-1.4/anymail/urls.py0000644000076500000240000000373413236203646020214 0ustar medmundsstaff00000000000000from django.conf.urls import url from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWebhookView app_name = 'anymail' urlpatterns = [ url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'), url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'), url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'), url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'), url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'), url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'), url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'), url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'), url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'), url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'), # Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme: url(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'), # This url is maintained for backwards compatibility with earlier Anymail releases: url(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'), ] django-anymail-1.4/anymail/utils.py0000644000076500000240000004620513236203646020367 0ustar medmundsstaff00000000000000import base64 import mimetypes from base64 import b64encode from collections import Mapping, MutableMapping from datetime import datetime from email.mime.base import MIMEBase from email.utils import formatdate, getaddresses, unquote from time import mktime import six from django.conf import settings from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from django.utils.encoding import force_text from django.utils.functional import Promise from django.utils.timezone import utc, get_fixed_timezone # noinspection PyUnresolvedReferences from six.moves.urllib.parse import urlsplit, urlunsplit from .exceptions import AnymailConfigurationError, AnymailInvalidAddress BASIC_NUMERIC_TYPES = six.integer_types + (float,) # int, float, and (on Python 2) long 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 = 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 def getfirst(dct, keys, default=UNSET): """Returns the value of the first of keys found in dict dct. >>> getfirst({'a': 1, 'b': 2}, ['c', 'a']) 1 >>> getfirst({'a': 1, 'b': 2}, ['b', 'a']) 2 >>> getfirst({'a': 1, 'b': 2}, ['c']) KeyError >>> getfirst({'a': 1, 'b': 2}, ['c'], None) None """ for key in keys: try: return dct[key] except KeyError: pass if default is UNSET: raise KeyError("None of %s found in dict" % ', '.join(keys)) else: return default def update_deep(dct, other): """Merge (recursively) keys and values from dict other into dict dct Works with dict-like objects: dct (and descendants) can be any MutableMapping, and other can be any Mapping """ for key, value in other.items(): if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping): update_deep(dct[key], value) else: dct[key] = value # (like dict.update(), no return value) def parse_address_list(address_list): """Returns a list of EmailAddress objects from strings in address_list. Essentially wraps :func:`email.utils.getaddresses` with better error messaging and more-useful output objects Note that the returned list might be longer than the address_list param, if any individual string contains multiple comma-separated addresses. :param list[str]|str|None|list[None] address_list: the address or addresses to parse :return list[:class:`EmailAddress`]: :raises :exc:`AnymailInvalidAddress`: """ if isinstance(address_list, six.string_types) or is_lazy(address_list): address_list = [address_list] if address_list is None or address_list == [None]: return [] # For consistency with Django's SMTP backend behavior, extract all addresses # from the list -- which may split comma-seperated strings into multiple addresses. # (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling; # also logic for ADDRESS_HEADERS in forbid_multi_line_headers.) address_list_strings = [force_text(address) for address in address_list] # resolve lazy strings name_email_pairs = getaddresses(address_list_strings) if name_email_pairs == [] and address_list_strings == [""]: name_email_pairs = [('', '')] # getaddresses ignores a single empty string parsed = [EmailAddress(display_name=name, addr_spec=email) for (name, email) in name_email_pairs] # Sanity-check, and raise useful errors for address in parsed: if address.username == '' or address.domain == '': # Django SMTP allows username-only emails, but they're not meaningful with an ESP errmsg = "Invalid email address '%s' parsed from '%s'." % ( address.addr_spec, ", ".join(address_list_strings)) if len(parsed) > len(address_list): errmsg += " (Maybe missing quotes around a display-name?)" raise AnymailInvalidAddress(errmsg) return parsed class EmailAddress(object): """A sanitized, complete email address with easy access to display-name, addr-spec (email), etc. Similar to Python 3.6+ email.headerregistry.Address Instance properties, all read-only: :ivar str display_name: the address's display-name portion (unqouted, unescaped), e.g., 'Display Name, Inc.' :ivar str addr_spec: the address's addr-spec portion (unquoted, unescaped), e.g., 'user@example.com' :ivar str username: the local part (before the '@') of the addr-spec, e.g., 'user' :ivar str domain: the domain part (after the '@') of the addr-spec, e.g., 'example.com' :ivar str address: the fully-formatted address, with any necessary quoting and escaping, e.g., '"Display Name, Inc." ' (also available as `str(EmailAddress)`) """ def __init__(self, display_name='', addr_spec=None): self._address = None # lazy formatted address if addr_spec is None: try: display_name, addr_spec = display_name # unpack (name,addr) tuple except ValueError: pass self.display_name = display_name self.addr_spec = addr_spec try: self.username, self.domain = addr_spec.split("@", 1) # do we need to unquote username? except ValueError: self.username = addr_spec self.domain = '' @property def address(self): if self._address is None: # (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here, # but that always forces the display-name to quoted-printable/base64, # even when simple ascii would work fine--and be more readable) self._address = self.formataddr() return self._address def formataddr(self, encoding=None): """Return a fully-formatted email address, using encoding. This is essentially the same as :func:`email.utils.formataddr` on the EmailAddress's name and email properties, but uses Django's :func:`~django.core.mail.message.sanitize_address` for improved PY2/3 compatibility, consistent handling of encoding (a.k.a. charset), and proper handling of IDN domain portions. :param str|None encoding: the charset to use for the display-name portion; default None uses ascii if possible, else 'utf-8' (quoted-printable utf-8/base64) """ return sanitize_address((self.display_name, self.addr_spec), encoding) def __str__(self): return self.address class Attachment(object): """A normalized EmailMessage.attachments item with additional functionality Normalized to have these properties: name: attachment filename; may be None content: bytestream mimetype: the content type; guessed if not explicit inline: bool, True if attachment has a Content-ID header content_id: for inline, the Content-ID (*with* <>); may be None cid: for inline, the Content-ID *without* <>; may be empty string """ 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 self.content_id = None self.cid = "" if isinstance(attachment, MIMEBase): self.name = attachment.get_filename() self.content = attachment.get_payload(decode=True) if self.content is None: if hasattr(attachment, 'as_bytes'): self.content = attachment.as_bytes() else: # Python 2.7 fallback self.content = attachment.as_string().encode(self.encoding) self.mimetype = attachment.get_content_type() if get_content_disposition(attachment) == 'inline': self.inline = True self.content_id = attachment["Content-ID"] # probably including the <...> if self.content_id is not None: self.cid = unquote(self.content_id) # without the <, > else: (self.name, self.content, self.mimetype) = attachment self.name = force_non_lazy(self.name) self.content = force_non_lazy(self.content) # 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") def get_content_disposition(mimeobj): """Return the message's content-disposition if it exists, or None. Backport of py3.5 :func:`~email.message.Message.get_content_disposition` """ value = mimeobj.get('content-disposition') if value is None: return None # _splitparam(value)[0].lower() : return str(value).partition(';')[0].strip().lower() 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: - 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 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): try: return getattr(settings, anymail_setting) except AttributeError: if allow_bare: try: return getattr(settings, setting) except AttributeError: pass if default is UNSET: message = "You must set %s or ANYMAIL = {'%s': ...}" % (anymail_setting, setting) if allow_bare: message += " or %s" % setting message += " in your Django settings" raise AnymailConfigurationError(message) else: return default def collect_all_methods(cls, method_name): """Return list of all `method_name` methods for cls and its superclass chain. List is in MRO order, with no duplicates. Methods are unbound. (This is used to simplify mixins and subclasses that contribute to a method set, without requiring superclass chaining, and without requiring cooperating superclasses.) """ methods = [] for ancestor in cls.__mro__: try: validator = getattr(ancestor, method_name) except AttributeError: pass else: if validator not in methods: methods.append(validator) return methods def querydict_getfirst(qdict, field, default=UNSET): """Like :func:`django.http.QueryDict.get`, but returns *first* value of multi-valued field. >>> from django.http import QueryDict >>> q = QueryDict('a=1&a=2&a=3') >>> querydict_getfirst(q, 'a') '1' >>> q.get('a') '3' >>> q['a'] '3' You can bind this to a QueryDict instance using the "descriptor protocol": >>> q.getfirst = querydict_getfirst.__get__(q) >>> q.getfirst('a') '1' """ # (Why not instead define a QueryDict subclass with this method? Because there's no simple way # to efficiently initialize a QueryDict subclass with the contents of an existing instance.) values = qdict.getlist(field) if len(values) > 0: return values[0] elif default is not UNSET: return default else: return qdict[field] # raise appropriate KeyError EPOCH = datetime(1970, 1, 1, tzinfo=utc) def timestamp(dt): """Return the unix timestamp (seconds past the epoch) for datetime dt""" # This is the equivalent of Python 3.3's datetime.timestamp try: return dt.timestamp() except AttributeError: if dt.tzinfo is None: return mktime(dt.timetuple()) else: return (dt - EPOCH).total_seconds() def rfc2822date(dt): """Turn a datetime into a date string as specified in RFC 2822.""" # This is almost the equivalent of Python 3.3's email.utils.format_datetime, # but treats naive datetimes as local rather than "UTC with no information ..." timeval = timestamp(dt) return formatdate(timeval, usegmt=True) def angle_wrap(s): """Return s surrounded by angle brackets, added only if necessary""" # This is the inverse behavior of email.utils.unquote # (which you might think email.utils.quote would do, but it doesn't) if len(s) > 0: if s[0] != '<': s = '<' + s if s[-1] != '>': s = s + '>' return s def is_lazy(obj): """Return True if obj is a Django lazy object.""" # See django.utils.functional.lazy. (This appears to be preferred # to checking for `not isinstance(obj, six.text_type)`.) return isinstance(obj, Promise) def force_non_lazy(obj): """If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged. (Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.) """ if is_lazy(obj): return six.text_type(obj) return obj def force_non_lazy_list(obj): """Return a (shallow) copy of sequence obj, with all values forced non-lazy.""" try: return [force_non_lazy(item) for item in obj] except (AttributeError, TypeError): return force_non_lazy(obj) def force_non_lazy_dict(obj): """Return a (deep) copy of dict obj, with all values forced non-lazy.""" try: return {key: force_non_lazy_dict(value) for key, value in obj.items()} except (AttributeError, TypeError): return force_non_lazy(obj) def get_request_basic_auth(request): """Returns HTTP basic auth string sent with request, or None. If request includes basic auth, result is string 'username:password'. """ try: authtype, authdata = request.META['HTTP_AUTHORIZATION'].split() if authtype.lower() == "basic": return base64.b64decode(authdata).decode('utf-8') except (IndexError, KeyError, TypeError, ValueError): pass return None def get_request_uri(request): """Returns the "exact" url used to call request. Like :func:`django.http.request.HTTPRequest.build_absolute_uri`, but also inlines HTTP basic auth, if present. """ url = request.build_absolute_uri() basic_auth = get_request_basic_auth(request) if basic_auth is not None: # must reassemble url with auth parts = urlsplit(url) url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc, parts.path, parts.query, parts.fragment)) return url try: from email.utils import parsedate_to_datetime # Python 3.3+ except ImportError: from email.utils import parsedate_tz # Backport Python 3.3+ email.utils.parsedate_to_datetime def parsedate_to_datetime(s): # *dtuple, tz = _parsedate_tz(data) dtuple = parsedate_tz(s) tz = dtuple[-1] # if tz is None: # parsedate_tz returns 0 for "-0000" if tz is None or (tz == 0 and "-0000" in s): # "... indicates that the date-time contains no information # about the local time zone" (RFC 2822 #3.3) return datetime(*dtuple[:6]) else: # tzinfo = datetime.timezone(datetime.timedelta(seconds=tz)) # Python 3.2+ only tzinfo = get_fixed_timezone(tz // 60) # don't use timedelta (avoid Django bug #28739) return datetime(*dtuple[:6], tzinfo=tzinfo) def parse_rfc2822date(s): """Parses an RFC-2822 formatted date string into a datetime.datetime Returns None if string isn't parseable. Returned datetime will be naive if string doesn't include known timezone offset; aware if it does. (Same as Python 3 email.utils.parsedate_to_datetime, with improved handling for unparseable date strings.) """ try: return parsedate_to_datetime(s) except (IndexError, TypeError, ValueError): # despite the docs, parsedate_to_datetime often dies on unparseable input return None django-anymail-1.4/anymail/webhooks/0000755000076500000240000000000013237125745020473 5ustar medmundsstaff00000000000000django-anymail-1.4/anymail/webhooks/__init__.py0000644000076500000240000000000012711003175022556 0ustar medmundsstaff00000000000000django-anymail-1.4/anymail/webhooks/base.py0000644000076500000240000001453013236647051021760 0ustar medmundsstaff00000000000000import warnings import six from django.http import HttpResponse from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth class AnymailBasicAuthMixin(object): """Implements webhook basic auth as mixin to AnymailBaseWebhookView.""" # Whether to warn if basic auth is not configured. # For most ESPs, basic auth is the only webhook security, # so the default is True. Subclasses can set False if # they enforce other security (like signed webhooks). warn_if_no_basic_auth = True # List of allowable HTTP basic-auth 'user:pass' strings. basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.) def __init__(self, **kwargs): self.basic_auth = get_anymail_setting('webhook_secret', default=[], kwargs=kwargs) # no esp_name -- auth is shared between ESPs if not self.basic_auth: # Temporarily allow deprecated WEBHOOK_AUTHORIZATION setting self.basic_auth = get_anymail_setting('webhook_authorization', default=[], kwargs=kwargs) # Allow a single string: if isinstance(self.basic_auth, six.string_types): self.basic_auth = [self.basic_auth] if self.warn_if_no_basic_auth and len(self.basic_auth) < 1: warnings.warn( "Your Anymail webhooks are insecure and open to anyone on the web. " "You should set WEBHOOK_SECRET in your ANYMAIL settings. " "See 'Securing webhooks' in the Anymail docs.", AnymailInsecureWebhookWarning) # noinspection PyArgumentList super(AnymailBasicAuthMixin, self).__init__(**kwargs) def validate_request(self, request): """If configured for webhook basic auth, validate request has correct auth.""" if self.basic_auth: request_auth = get_request_basic_auth(request) # Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any() # can terminate early: we're not trying to protect how many auth strings are allowed, # just the contents of each individual auth string.) auth_ok = any(constant_time_compare(request_auth, allowed_auth) for allowed_auth in self.basic_auth) if not auth_ok: # noinspection PyUnresolvedReferences raise AnymailWebhookValidationFailure( "Missing or invalid basic auth in Anymail %s webhook" % self.esp_name) # Mixin note: Django's View.__init__ doesn't cooperate with chaining, # so all mixins that need __init__ must appear before View in MRO. class AnymailBaseWebhookView(AnymailBasicAuthMixin, View): """Base view for processing ESP event webhooks ESP-specific implementations should subclass and implement parse_events. They may also want to implement validate_request if additional security is available. """ def __init__(self, **kwargs): super(AnymailBaseWebhookView, self).__init__(**kwargs) self.validators = collect_all_methods(self.__class__, 'validate_request') # Subclass implementation: # Where to send events: either ..signals.inbound or ..signals.tracking signal = None def validate_request(self, request): """Check validity of webhook post, or raise AnymailWebhookValidationFailure. AnymailBaseWebhookView includes basic auth validation. Subclasses can implement (or provide via mixins) if the ESP supports additional validation (such as signature checking). *All* definitions of this method in the class chain (including mixins) will be called. There is no need to chain to the superclass. (See self.run_validators and collect_all_methods.) Security note: use django.utils.crypto.constant_time_compare for string comparisons, to avoid exposing your validation to a timing attack. """ # if not constant_time_compare(request.POST['signature'], expected_signature): # raise AnymailWebhookValidationFailure("...message...") # (else just do nothing) pass def parse_events(self, request): """Return a list of normalized AnymailWebhookEvent extracted from ESP post data. Subclasses must implement. """ raise NotImplementedError() # HTTP handlers (subclasses shouldn't need to override): http_method_names = ["post", "head", "options"] @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs) def head(self, request, *args, **kwargs): # Some ESPs verify the webhook with a HEAD request at configuration time return HttpResponse() def post(self, request, *args, **kwargs): # Normal Django exception handling will do the right thing: # - AnymailWebhookValidationFailure will turn into an HTTP 400 response # (via Django SuspiciousOperation handling) # - Any other errors (e.g., in signal dispatch) will turn into HTTP 500 # responses (via normal Django error handling). ESPs generally # treat that as "try again later". self.run_validators(request) events = self.parse_events(request) esp_name = self.esp_name for event in events: self.signal.send(sender=self.__class__, event=event, esp_name=esp_name) return HttpResponse() # Request validation (subclasses shouldn't need to override): def run_validators(self, request): for validator in self.validators: validator(self, request) @property def esp_name(self): """ Read-only name of the ESP for this webhook view. Subclasses must override with class attr. E.g.: esp_name = "Postmark" esp_name = "SendGrid" # (use ESP's preferred capitalization) """ raise NotImplementedError("%s.%s must declare esp_name class attr" % (self.__class__.__module__, self.__class__.__name__)) django-anymail-1.4/anymail/webhooks/mailgun.py0000644000076500000240000003056113236203646022502 0ustar medmundsstaff00000000000000import json from datetime import datetime import hashlib import hmac from django.utils.crypto import constant_time_compare from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..exceptions import AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason from ..utils import get_anymail_setting, combine, querydict_getfirst class MailgunBaseWebhookView(AnymailBaseWebhookView): """Base view class for Mailgun webhooks""" esp_name = "Mailgun" warn_if_no_basic_auth = False # because we validate against signature api_key = None # (Declaring class attr allows override by kwargs in View.as_view.) def __init__(self, **kwargs): api_key = get_anymail_setting('api_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True) self.api_key = api_key.encode('ascii') # hmac.new requires bytes key in python 3 super(MailgunBaseWebhookView, self).__init__(**kwargs) def validate_request(self, request): super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled try: # Must use the *last* value of these fields if there are conflicting merged user-variables. # (Fortunately, Django QueryDict is specced to return the last value.) token = request.POST['token'] timestamp = request.POST['timestamp'] signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2) except KeyError: raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields") expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'), digestmod=hashlib.sha256).hexdigest() if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature") class MailgunTrackingWebhookView(MailgunBaseWebhookView): """Handler for Mailgun delivery and engagement tracking webhooks""" signal = tracking event_types = { # Map Mailgun event: Anymail normalized type 'delivered': EventType.DELIVERED, 'dropped': EventType.REJECTED, 'bounced': EventType.BOUNCED, 'complained': EventType.COMPLAINED, 'unsubscribed': EventType.UNSUBSCRIBED, 'opened': EventType.OPENED, 'clicked': EventType.CLICKED, # Mailgun does not send events corresponding to QUEUED or DEFERRED } reject_reasons = { # Map Mailgun (SMTP) error codes to Anymail normalized reject_reason. # By default, we will treat anything 400-599 as REJECT_BOUNCED # so only exceptions are listed here. 499: RejectReason.TIMED_OUT, # unable to connect to MX (also covers invalid recipients) # These 6xx codes appear to be Mailgun extensions to SMTP # (and don't seem to be documented anywhere): 605: RejectReason.BOUNCED, # previous bounce 607: RejectReason.SPAM, # previous spam complaint } def parse_events(self, request): return [self.esp_to_anymail_event(request.POST)] def esp_to_anymail_event(self, esp_event): # esp_event is a Django QueryDict (from request.POST), # which has multi-valued fields, but is *not* case-insensitive. # Because of the way Mailgun merges user-variables into the event, # we must generally use the *first* value of any multi-valued field # to avoid potential conflicting user-data. esp_event.getfirst = querydict_getfirst.__get__(esp_event) event_type = self.event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN) timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp # Message-Id is not documented for every event, but seems to always be included. # (It's sometimes spelled as 'message-id', lowercase, and missing the .) message_id = esp_event.getfirst('Message-Id', None) or esp_event.getfirst('message-id', None) if message_id and not message_id.startswith('<'): message_id = "<{}>".format(message_id) description = esp_event.getfirst('description', None) mta_response = esp_event.getfirst('error', None) or esp_event.getfirst('notification', None) reject_reason = None try: mta_status = int(esp_event.getfirst('code')) except (KeyError, TypeError): pass except ValueError: # RFC-3463 extended SMTP status code (class.subject.detail, where class is "2", "4" or "5") try: status_class = esp_event.getfirst('code').split('.')[0] except (TypeError, IndexError): # illegal SMTP status code format pass else: reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER else: reject_reason = self.reject_reasons.get( mta_status, RejectReason.BOUNCED if 400 <= mta_status < 600 else RejectReason.OTHER) metadata = self._extract_metadata(esp_event) # tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', []) return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=esp_event.get('token', None), # use *last* value of token recipient=esp_event.getfirst('recipient', None), reject_reason=reject_reason, description=description, mta_response=mta_response, tags=tags, metadata=metadata, click_url=esp_event.getfirst('url', None), user_agent=esp_event.getfirst('user-agent', None), esp_event=esp_event, ) def _extract_metadata(self, esp_event): # Mailgun merges user-variables into the POST fields. If you know which user variable # you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine. # But if you want to extract all user-variables (like we do), it's more complicated... event_type = esp_event.getfirst('event') metadata = {} if 'message-headers' in esp_event: # For events where original message headers are available, it's most reliable # to recover user-variables from the X-Mailgun-Variables header(s). headers = json.loads(esp_event['message-headers']) variables = [value for [field, value] in headers if field == 'X-Mailgun-Variables'] if len(variables) >= 1: # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict: metadata = combine(*[json.loads(value) for value in variables]) elif event_type in self._known_event_fields: # For other events, we must extract from the POST fields, ignoring known Mailgun # event parameters, and treating all other values as user-variables. known_fields = self._known_event_fields[event_type] for field, values in esp_event.lists(): if field not in known_fields: # Unknown fields are assumed to be user-variables. (There should really only be # a single value, but just in case take the last one to match QueryDict semantics.) metadata[field] = values[-1] elif field == 'tag': # There's no way to distinguish a user-variable named 'tag' from an actual tag, # so don't treat this/these value(s) as metadata. pass elif len(values) == 1: # This is an expected event parameter, and since there's only a single value # it must be the event param, not metadata. pass else: # This is an expected event parameter, but there are (at least) two values. # One is the event param, and the other is a user-variable metadata value. # Which is which depends on the field: if field in {'signature', 'timestamp', 'token'}: metadata[field] = values[0] # values = [user-variable, event-param] else: metadata[field] = values[-1] # values = [event-param, user-variable] return metadata _common_event_fields = { # These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events: 'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type', 'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list', 'timestamp', 'token', 'signature', # Undocumented, but observed in actual events: 'body-plain', 'h', 'message-id', } _known_event_fields = { # For all Mailgun event types that *don't* include message-headers, # map Mailgun (not normalized) event type to set of expected event fields. # Used for metadata extraction. 'clicked': _common_event_fields | {'url'}, 'opened': _common_event_fields, 'unsubscribed': _common_event_fields, } class MailgunInboundWebhookView(MailgunBaseWebhookView): """Handler for Mailgun inbound (route forward-to-url) webhook""" signal = inbound def parse_events(self, request): return [self.esp_to_anymail_event(request)] def esp_to_anymail_event(self, request): # Inbound uses the entire Django request as esp_event, because we need POST and FILES. # Note that request.POST is case-sensitive (unlike email.message.Message headers). esp_event = request if 'body-mime' in request.POST: # Raw-MIME message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime']) else: # Fully-parsed message = self.message_from_mailgun_parsed(request) message.envelope_sender = request.POST.get('sender', None) message.envelope_recipient = request.POST.get('recipient', None) message.stripped_text = request.POST.get('stripped-text', None) message.stripped_html = request.POST.get('stripped-html', None) message.spam_detected = message.get('X-Mailgun-Sflag', 'No').lower() == 'yes' try: message.spam_score = float(message['X-Mailgun-Sscore']) except (TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=datetime.fromtimestamp(int(request.POST['timestamp']), tz=utc), event_id=request.POST.get('token', None), esp_event=esp_event, message=message, ) def message_from_mailgun_parsed(self, request): """Construct a Message from Mailgun's "fully-parsed" fields""" # Mailgun transcodes all fields to UTF-8 for "fully parsed" messages try: attachment_count = int(request.POST['attachment-count']) except (KeyError, TypeError): attachments = None else: # Load attachments from posted files: Mailgun file field names are 1-based att_ids = ['attachment-%d' % i for i in range(1, attachment_count+1)] att_cids = { # filename: content-id (invert content-id-map) att_id: cid for cid, att_id in json.loads(request.POST.get('content-id-map', '{}')).items() } attachments = [ AnymailInboundMessage.construct_attachment_from_uploaded_file( request.FILES[att_id], content_id=att_cids.get(att_id, None)) for att_id in att_ids ] return AnymailInboundMessage.construct( headers=json.loads(request.POST['message-headers']), # includes From, To, Cc, Subject, etc. text=request.POST.get('body-plain', None), html=request.POST.get('body-html', None), attachments=attachments, ) django-anymail-1.4/anymail/webhooks/mailjet.py0000644000076500000240000001673713236203646022504 0ustar medmundsstaff00000000000000import json from datetime import datetime from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason class MailjetTrackingWebhookView(AnymailBaseWebhookView): """Handler for Mailjet delivery and engagement tracking webhooks""" esp_name = "Mailjet" signal = tracking def parse_events(self, request): esp_events = json.loads(request.body.decode('utf-8')) return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] # https://dev.mailjet.com/guides/#events event_types = { # Map Mailjet event: Anymail normalized type 'sent': EventType.DELIVERED, # accepted by receiving MTA 'open': EventType.OPENED, 'click': EventType.CLICKED, 'bounce': EventType.BOUNCED, 'blocked': EventType.REJECTED, 'spam': EventType.COMPLAINED, 'unsub': EventType.UNSUBSCRIBED, } reject_reasons = { # Map Mailjet error strings to Anymail normalized reject_reason # error_related_to: recipient 'user unknown': RejectReason.BOUNCED, 'mailbox inactive': RejectReason.BOUNCED, 'quota exceeded': RejectReason.BOUNCED, 'blacklisted': RejectReason.BLOCKED, # might also be previous unsubscribe 'spam reporter': RejectReason.SPAM, # error_related_to: domain 'invalid domain': RejectReason.BOUNCED, 'no mail host': RejectReason.BOUNCED, 'relay/access denied': RejectReason.BOUNCED, 'greylisted': RejectReason.OTHER, # see special handling below 'typofix': RejectReason.INVALID, # error_related_to: spam (all Mailjet policy/filtering; see above for spam complaints) 'sender blocked': RejectReason.BLOCKED, 'content blocked': RejectReason.BLOCKED, 'policy issue': RejectReason.BLOCKED, # error_related_to: mailjet 'preblocked': RejectReason.BLOCKED, 'duplicate in campaign': RejectReason.OTHER, } def esp_to_anymail_event(self, esp_event): event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN) if esp_event.get('error', None) == 'greylisted' and not esp_event.get('hard_bounce', False): # "This is a temporary error due to possible unrecognised senders. Delivery will be re-attempted." event_type = EventType.DEFERRED try: timestamp = datetime.fromtimestamp(esp_event['time'], tz=utc) except (KeyError, ValueError): timestamp = None try: # convert bigint MessageID to str to match backend AnymailRecipientStatus message_id = str(esp_event['MessageID']) except (KeyError, TypeError): message_id = None if 'error' in esp_event: reject_reason = self.reject_reasons.get(esp_event['error'], RejectReason.OTHER) else: reject_reason = None tag = esp_event.get('customcampaign', None) tags = [tag] if tag else [] try: metadata = json.loads(esp_event['Payload']) except (KeyError, ValueError): metadata = {} return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=None, recipient=esp_event.get('email', None), reject_reason=reject_reason, mta_response=esp_event.get('smtp_reply', None), tags=tags, metadata=metadata, click_url=esp_event.get('url', None), user_agent=esp_event.get('agent', None), esp_event=esp_event, ) class MailjetInboundWebhookView(AnymailBaseWebhookView): """Handler for Mailjet inbound (parse API) webhook""" esp_name = "Mailjet" signal = inbound def parse_events(self, request): esp_event = json.loads(request.body.decode('utf-8')) return [self.esp_to_anymail_event(esp_event)] def esp_to_anymail_event(self, esp_event): # You could _almost_ reconstruct the raw mime message from Mailjet's Headers and Parts fields, # but it's not clear which multipart boundary to use on each individual Part. Although each Part's # Content-Type header still has the multipart boundary, not knowing the parent part means typical # nested multipart structures can't be reliably recovered from the data Mailjet provides. # We'll just use our standarized multipart inbound constructor. headers = self._flatten_mailjet_headers(esp_event.get("Headers", {})) attachments = [ self._construct_mailjet_attachment(part, esp_event) for part in esp_event.get("Parts", []) if "Attachment" in part.get("ContentRef", "") # Attachment or InlineAttachment ] message = AnymailInboundMessage.construct( headers=headers, text=esp_event.get("Text-part", None), html=esp_event.get("Html-part", None), attachments=attachments, ) message.envelope_sender = esp_event.get("Sender", None) message.envelope_recipient = esp_event.get("Recipient", None) message.spam_detected = None # Mailjet doesn't provide a boolean; you'll have to interpret spam_score try: message.spam_score = float(esp_event['SpamAssassinScore']) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # Mailjet doesn't provide inbound event timestamp (esp_event['Date'] is time sent) event_id=None, # Mailjet doesn't provide an idempotent inbound event id esp_event=esp_event, message=message, ) @staticmethod def _flatten_mailjet_headers(headers): """Convert Mailjet's dict-of-strings-and/or-lists header format to our list-of-name-value-pairs {'name1': 'value', 'name2': ['value1', 'value2']} --> [('name1', 'value'), ('name2', 'value1'), ('name2', 'value2')] """ result = [] for name, values in headers.items(): if isinstance(values, list): # Mailjet groups repeated headers together as a list of values for value in values: result.append((name, value)) else: result.append((name, values)) # single-valued (non-list) header return result def _construct_mailjet_attachment(self, part, esp_event): # Mailjet includes unparsed attachment headers in each part; it's easiest to temporarily # attach them to a MIMEPart for parsing. (We could just turn this into the attachment, # but we want to use the payload handling from AnymailInboundMessage.construct_attachment later.) part_headers = AnymailInboundMessage() # temporary container for parsed attachment headers for name, value in self._flatten_mailjet_headers(part.get("Headers", {})): part_headers.add_header(name, value) content_base64 = esp_event[part["ContentRef"]] # Mailjet *always* base64-encodes attachments return AnymailInboundMessage.construct_attachment( content_type=part_headers.get_content_type(), content=content_base64, base64=True, filename=part_headers.get_filename(None), content_id=part_headers.get("Content-ID", "") or None, ) django-anymail-1.4/anymail/webhooks/mandrill.py0000644000076500000240000001655213236203646022654 0ustar medmundsstaff00000000000000import json from datetime import datetime import hashlib import hmac from base64 import b64encode from django.utils.crypto import constant_time_compare from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..exceptions import AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType from ..utils import get_anymail_setting, getfirst, get_request_uri class MandrillSignatureMixin(object): """Validates Mandrill webhook signature""" # These can be set from kwargs in View.as_view, or pulled from settings in init: webhook_key = None # required webhook_url = None # optional; defaults to actual url used def __init__(self, **kwargs): # noinspection PyUnresolvedReferences esp_name = self.esp_name # webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url. # Defer "missing setting" error until we actually try to use it in the POST... webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) if webhook_key is not None: self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3 self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) # noinspection PyArgumentList super(MandrillSignatureMixin, self).__init__(**kwargs) def validate_request(self, request): if self.webhook_key is None: # issue deferred "missing setting" error (re-call get-setting without a default) # noinspection PyUnresolvedReferences get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True) try: signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] except KeyError: raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") # Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params: url = self.webhook_url or get_request_uri(request) params = request.POST.dict() signed_data = url for key in sorted(params.keys()): signed_data += key + params[key] expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'), digestmod=hashlib.sha1).digest()) if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure( "Mandrill webhook called with incorrect signature (for url %r)" % url) class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView): """Unified view class for Mandrill tracking and inbound webhooks""" esp_name = "Mandrill" warn_if_no_basic_auth = False # because we validate against signature signal = None # set in esp_to_anymail_event def parse_events(self, request): esp_events = json.loads(request.POST['mandrill_events']) return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] def esp_to_anymail_event(self, esp_event): """Route events to the inbound or tracking handler""" esp_type = getfirst(esp_event, ['event', 'type'], 'unknown') if esp_type == 'inbound': assert self.signal is not tracking # Mandrill should never mix event types in the same batch self.signal = inbound return self.mandrill_inbound_to_anymail_event(esp_event) else: assert self.signal is not inbound # Mandrill should never mix event types in the same batch self.signal = tracking return self.mandrill_tracking_to_anymail_event(esp_event) # # Tracking events # event_types = { # Message events: 'send': EventType.SENT, 'deferral': EventType.DEFERRED, 'hard_bounce': EventType.BOUNCED, 'soft_bounce': EventType.BOUNCED, 'open': EventType.OPENED, 'click': EventType.CLICKED, 'spam': EventType.COMPLAINED, 'unsub': EventType.UNSUBSCRIBED, 'reject': EventType.REJECTED, # Sync events (we don't really normalize these well): 'whitelist': EventType.UNKNOWN, 'blacklist': EventType.UNKNOWN, # Inbound events: 'inbound': EventType.INBOUND, } def mandrill_tracking_to_anymail_event(self, esp_event): esp_type = getfirst(esp_event, ['event', 'type'], None) event_type = self.event_types.get(esp_type, EventType.UNKNOWN) try: timestamp = datetime.fromtimestamp(esp_event['ts'], tz=utc) except (KeyError, ValueError): timestamp = None try: recipient = esp_event['msg']['email'] except KeyError: try: recipient = esp_event['reject']['email'] # sync events except KeyError: recipient = None try: mta_response = esp_event['msg']['diag'] except KeyError: mta_response = None try: description = getfirst(esp_event['reject'], ['detail', 'reason']) except KeyError: description = None try: metadata = esp_event['msg']['metadata'] except KeyError: metadata = {} try: tags = esp_event['msg']['tags'] except KeyError: tags = [] return AnymailTrackingEvent( click_url=esp_event.get('url', None), description=description, esp_event=esp_event, event_type=event_type, message_id=esp_event.get('_id', None), metadata=metadata, mta_response=mta_response, recipient=recipient, reject_reason=None, # probably map esp_event['msg']['bounce_description'], but insufficient docs tags=tags, timestamp=timestamp, user_agent=esp_event.get('user_agent', None), ) # # Inbound events # def mandrill_inbound_to_anymail_event(self, esp_event): # It's easier (and more accurate) to just work from the original raw mime message message = AnymailInboundMessage.parse_raw_mime(esp_event['msg']['raw_msg']) message.envelope_sender = None # (Mandrill's 'sender' field only applies to outbound messages) message.envelope_recipient = esp_event['msg'].get('email', None) message.spam_detected = None # no simple boolean field; would need to parse the spam_report message.spam_score = esp_event['msg'].get('spam_report', {}).get('score', None) try: timestamp = datetime.fromtimestamp(esp_event['ts'], tz=utc) except (KeyError, ValueError): timestamp = None return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=timestamp, event_id=None, # Mandrill doesn't provide an idempotent inbound message event id esp_event=esp_event, message=message, ) # Backwards-compatibility: earlier Anymail versions had only MandrillTrackingWebhookView: MandrillTrackingWebhookView = MandrillCombinedWebhookView django-anymail-1.4/anymail/webhooks/postmark.py0000644000076500000240000001737713236203646022720 0ustar medmundsstaff00000000000000import json from django.utils.dateparse import parse_datetime from .base import AnymailBaseWebhookView from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason from ..utils import getfirst, EmailAddress class PostmarkBaseWebhookView(AnymailBaseWebhookView): """Base view class for Postmark webhooks""" esp_name = "Postmark" def parse_events(self, request): esp_event = json.loads(request.body.decode('utf-8')) return [self.esp_to_anymail_event(esp_event)] def esp_to_anymail_event(self, esp_event): raise NotImplementedError() class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): """Handler for Postmark delivery and engagement tracking webhooks""" signal = tracking event_types = { # Map Postmark event type: Anymail normalized (event type, reject reason) 'HardBounce': (EventType.BOUNCED, RejectReason.BOUNCED), 'Transient': (EventType.DEFERRED, None), 'Unsubscribe': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), 'Subscribe': (EventType.SUBSCRIBED, None), 'AutoResponder': (EventType.AUTORESPONDED, None), 'AddressChange': (EventType.AUTORESPONDED, None), 'DnsError': (EventType.DEFERRED, None), # "temporary DNS error" 'SpamNotification': (EventType.COMPLAINED, RejectReason.SPAM), 'OpenRelayTest': (EventType.DEFERRED, None), # Receiving MTA is testing Postmark 'Unknown': (EventType.UNKNOWN, None), 'SoftBounce': (EventType.BOUNCED, RejectReason.BOUNCED), # might also receive HardBounce later 'VirusNotification': (EventType.BOUNCED, RejectReason.OTHER), 'ChallengeVerification': (EventType.AUTORESPONDED, None), 'BadEmailAddress': (EventType.REJECTED, RejectReason.INVALID), 'SpamComplaint': (EventType.COMPLAINED, RejectReason.SPAM), 'ManuallyDeactivated': (EventType.REJECTED, RejectReason.BLOCKED), 'Unconfirmed': (EventType.REJECTED, None), 'Blocked': (EventType.REJECTED, RejectReason.BLOCKED), 'SMTPApiError': (EventType.FAILED, None), # could occur if user also using Postmark SMTP directly 'InboundError': (EventType.INBOUND_FAILED, None), 'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED), 'TemplateRenderingFailed': (EventType.FAILED, None), # DELIVERED doesn't have a Type field; detected separately below # CLICKED doesn't have a Type field; detected separately below # OPENED doesn't have a Type field; detected separately below # INBOUND doesn't have a Type field; should come in through different webhook } def esp_to_anymail_event(self, esp_event): reject_reason = None try: esp_type = esp_event['Type'] event_type, reject_reason = self.event_types.get(esp_type, (EventType.UNKNOWN, None)) except KeyError: if 'FirstOpen' in esp_event: event_type = EventType.OPENED elif 'OriginalLink' in esp_event: event_type = EventType.CLICKED elif 'DeliveredAt' in esp_event: event_type = EventType.DELIVERED elif 'From' in esp_event: # This is an inbound event raise AnymailConfigurationError( "You seem to have set Postmark's *inbound* webhook URL " "to Anymail's Postmark *tracking* webhook URL.") else: event_type = EventType.UNKNOWN recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open try: timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt']) except KeyError: timestamp = None else: timestamp = parse_datetime(timestr) try: event_id = str(esp_event['ID']) # only in bounce events except KeyError: event_id = None try: tags = [esp_event['Tag']] except KeyError: tags = [] return AnymailTrackingEvent( description=esp_event.get('Description', None), esp_event=esp_event, event_id=event_id, event_type=event_type, message_id=esp_event.get('MessageID', None), mta_response=esp_event.get('Details', None), recipient=recipient, reject_reason=reject_reason, tags=tags, timestamp=timestamp, user_agent=esp_event.get('UserAgent', None), click_url=esp_event.get('OriginalLink', None), ) class PostmarkInboundWebhookView(PostmarkBaseWebhookView): """Handler for Postmark inbound webhook""" signal = inbound def esp_to_anymail_event(self, esp_event): attachments = [ AnymailInboundMessage.construct_attachment( content_type=attachment["ContentType"], content=attachment["Content"], base64=True, filename=attachment.get("Name", "") or None, content_id=attachment.get("ContentID", "") or None, ) for attachment in esp_event.get("Attachments", []) ] message = AnymailInboundMessage.construct( from_email=self._address(esp_event.get("FromFull")), to=', '.join([self._address(to) for to in esp_event.get("ToFull", [])]), cc=', '.join([self._address(cc) for cc in esp_event.get("CcFull", [])]), # bcc? Postmark specs this for inbound events, but it's unclear how it could occur subject=esp_event.get("Subject", ""), headers=[(header["Name"], header["Value"]) for header in esp_event.get("Headers", [])], text=esp_event.get("TextBody", ""), html=esp_event.get("HtmlBody", ""), attachments=attachments, ) # Postmark strips these headers and provides them as separate event fields: if "Date" in esp_event and "Date" not in message: message["Date"] = esp_event["Date"] if "ReplyTo" in esp_event and "Reply-To" not in message: message["Reply-To"] = esp_event["ReplyTo"] # Postmark doesn't have a separate envelope-sender field, but it can be extracted # from the Received-SPF header that Postmark will have added: if len(message.get_all("Received-SPF", [])) == 1: # (more than one? someone's up to something weird) received_spf = message["Received-SPF"].lower() if received_spf.startswith("pass") or received_spf.startswith("neutral"): # not fail/softfail message.envelope_sender = message.get_param("envelope-from", None, header="Received-SPF") message.envelope_recipient = esp_event.get("OriginalRecipient", None) message.stripped_text = esp_event.get("StrippedTextReply", None) message.spam_detected = message.get('X-Spam-Status', 'No').lower() == 'yes' try: message.spam_score = float(message['X-Spam-Score']) except (TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # Postmark doesn't provide inbound event timestamp event_id=esp_event.get("MessageID", None), # Postmark uuid, different from Message-ID mime header esp_event=esp_event, message=message, ) @staticmethod def _address(full): """Return an formatted email address from a Postmark inbound {From,To,Cc}Full dict""" if full is None: return "" return str(EmailAddress( display_name=full.get('Name', ""), addr_spec=full.get("Email", ""), )) django-anymail-1.4/anymail/webhooks/sendgrid.py0000644000076500000240000001642113236203646022644 0ustar medmundsstaff00000000000000import json from datetime import datetime from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason class SendGridTrackingWebhookView(AnymailBaseWebhookView): """Handler for SendGrid delivery and engagement tracking webhooks""" esp_name = "SendGrid" signal = tracking def parse_events(self, request): esp_events = json.loads(request.body.decode('utf-8')) return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] event_types = { # Map SendGrid event: Anymail normalized type 'bounce': EventType.BOUNCED, 'deferred': EventType.DEFERRED, 'delivered': EventType.DELIVERED, 'dropped': EventType.REJECTED, 'processed': EventType.QUEUED, 'click': EventType.CLICKED, 'open': EventType.OPENED, 'spamreport': EventType.COMPLAINED, 'unsubscribe': EventType.UNSUBSCRIBED, 'group_unsubscribe': EventType.UNSUBSCRIBED, 'group_resubscribe': EventType.SUBSCRIBED, } reject_reasons = { # Map SendGrid reason/type strings (lowercased) to Anymail normalized reject_reason 'invalid': RejectReason.INVALID, 'unsubscribed address': RejectReason.UNSUBSCRIBED, 'bounce': RejectReason.BOUNCED, 'blocked': RejectReason.BLOCKED, 'expired': RejectReason.TIMED_OUT, } def esp_to_anymail_event(self, esp_event): event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN) try: timestamp = datetime.fromtimestamp(esp_event['timestamp'], tz=utc) except (KeyError, ValueError): timestamp = None if esp_event['event'] == 'dropped': mta_response = None # dropped at ESP before even getting to MTA reason = esp_event.get('type', esp_event.get('reason', '')) # cause could be in 'type' or 'reason' reject_reason = self.reject_reasons.get(reason.lower(), RejectReason.OTHER) else: # MTA response is in 'response' for delivered; 'reason' for bounce mta_response = esp_event.get('response', esp_event.get('reason', None)) reject_reason = None # SendGrid merges metadata ('unique_args') with the event. # We can (sort of) split metadata back out by filtering known # SendGrid event params, though this can miss metadata keys # that duplicate SendGrid params, and can accidentally include # non-metadata keys if SendGrid modifies their event records. metadata_keys = set(esp_event.keys()) - self.sendgrid_event_keys if len(metadata_keys) > 0: metadata = {key: esp_event[key] for key in metadata_keys} else: metadata = {} return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=esp_event.get('smtp-id', None), event_id=esp_event.get('sg_event_id', None), recipient=esp_event.get('email', None), reject_reason=reject_reason, mta_response=mta_response, tags=esp_event.get('category', []), metadata=metadata, click_url=esp_event.get('url', None), user_agent=esp_event.get('useragent', None), esp_event=esp_event, ) # Known keys in SendGrid events (used to recover metadata above) sendgrid_event_keys = { 'asm_group_id', 'attempt', # MTA deferred count 'category', 'cert_err', 'email', 'event', 'ip', 'marketing_campaign_id', 'marketing_campaign_name', 'newsletter', # ??? 'nlvx_campaign_id', 'nlvx_campaign_split_id', 'nlvx_user_id', 'pool', 'post_type', 'reason', # MTA bounce/drop reason; SendGrid suppression reason 'response', # MTA deferred/delivered message 'send_at', 'sg_event_id', 'sg_message_id', 'smtp-id', 'status', # SMTP status code 'timestamp', 'tls', 'type', # suppression reject reason ("bounce", "blocked", "expired") 'url', # click tracking 'url_offset', # click tracking 'useragent', # click/open tracking } class SendGridInboundWebhookView(AnymailBaseWebhookView): """Handler for SendGrid inbound webhook""" esp_name = "SendGrid" signal = inbound def parse_events(self, request): return [self.esp_to_anymail_event(request)] def esp_to_anymail_event(self, request): # Inbound uses the entire Django request as esp_event, because we need POST and FILES. # Note that request.POST is case-sensitive (unlike email.message.Message headers). esp_event = request if 'headers' in request.POST: # Default (not "Send Raw") inbound fields message = self.message_from_sendgrid_parsed(esp_event) elif 'email' in request.POST: # "Send Raw" full MIME message = AnymailInboundMessage.parse_raw_mime(request.POST['email']) else: raise KeyError("Invalid SendGrid inbound event data (missing both 'headers' and 'email' fields)") try: envelope = json.loads(request.POST['envelope']) except (KeyError, TypeError, ValueError): pass else: message.envelope_sender = envelope['from'] message.envelope_recipient = envelope['to'][0] message.spam_detected = None # no simple boolean field; would need to parse the spam_report try: message.spam_score = float(request.POST['spam_score']) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # SendGrid doesn't provide an inbound event timestamp event_id=None, # SendGrid doesn't provide an idempotent inbound message event id esp_event=esp_event, message=message, ) def message_from_sendgrid_parsed(self, request): """Construct a Message from SendGrid's "default" (non-raw) fields""" try: charsets = json.loads(request.POST['charsets']) except (KeyError, ValueError): charsets = {} try: attachment_info = json.loads(request.POST['attachment-info']) except (KeyError, ValueError): attachments = None else: # Load attachments from posted files attachments = [ AnymailInboundMessage.construct_attachment_from_uploaded_file( request.FILES[att_id], content_id=attachment_info[att_id].get("content-id", None)) for att_id in sorted(attachment_info.keys()) ] return AnymailInboundMessage.construct( raw_headers=request.POST.get('headers', ""), # includes From, To, Cc, Subject, etc. text=request.POST.get('text', None), text_charset=charsets.get('text', 'utf-8'), html=request.POST.get('html', None), html_charset=charsets.get('html', 'utf-8'), attachments=attachments, ) django-anymail-1.4/anymail/webhooks/sparkpost.py0000644000076500000240000001700713236203646023074 0ustar medmundsstaff00000000000000import json from base64 import b64decode from datetime import datetime from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason class SparkPostBaseWebhookView(AnymailBaseWebhookView): """Base view class for SparkPost webhooks""" esp_name = "SparkPost" def parse_events(self, request): raw_events = json.loads(request.body.decode('utf-8')) unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events] return [ self.esp_to_anymail_event(event_class, event, raw_event) for (event_class, event, raw_event) in unwrapped_events if event is not None # filter out empty "ping" events ] def unwrap_event(self, raw_event): """Unwraps SparkPost event structure, and returns event_class, event, raw_event raw_event is of form {'msys': {event_class: {...event...}}} Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}} """ event_classes = raw_event['msys'].keys() try: (event_class,) = event_classes event = raw_event['msys'][event_class] except ValueError: # too many/not enough event_classes to unpack if len(event_classes) == 0: # Empty event (SparkPost sometimes sends as a "ping") event_class = event = None else: raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) return event_class, event, raw_event def esp_to_anymail_event(self, event_class, event, raw_event): raise NotImplementedError() class SparkPostTrackingWebhookView(SparkPostBaseWebhookView): """Handler for SparkPost message, engagement, and generation event webhooks""" signal = tracking event_types = { # Map SparkPost event.type: Anymail normalized type 'bounce': EventType.BOUNCED, 'delivery': EventType.DELIVERED, 'injection': EventType.QUEUED, 'spam_complaint': EventType.COMPLAINED, 'out_of_band': EventType.BOUNCED, 'policy_rejection': EventType.REJECTED, 'delay': EventType.DEFERRED, 'click': EventType.CLICKED, 'open': EventType.OPENED, 'generation_failure': EventType.FAILED, 'generation_rejection': EventType.REJECTED, 'list_unsubscribe': EventType.UNSUBSCRIBED, 'link_unsubscribe': EventType.UNSUBSCRIBED, } reject_reasons = { # Map SparkPost event.bounce_class: Anymail normalized reject reason. # Can also supply (RejectReason, EventType) for bounce_class that affects our event_type. # https://support.sparkpost.com/customer/portal/articles/1929896 '1': RejectReason.OTHER, # Undetermined (response text could not be identified) '10': RejectReason.INVALID, # Invalid Recipient '20': RejectReason.BOUNCED, # Soft Bounce '21': RejectReason.BOUNCED, # DNS Failure '22': RejectReason.BOUNCED, # Mailbox Full '23': RejectReason.BOUNCED, # Too Large '24': RejectReason.TIMED_OUT, # Timeout '25': RejectReason.BLOCKED, # Admin Failure (configured policies) '30': RejectReason.BOUNCED, # Generic Bounce: No RCPT '40': RejectReason.BOUNCED, # Generic Bounce: unspecified reasons '50': RejectReason.BLOCKED, # Mail Block (by the receiver) '51': RejectReason.SPAM, # Spam Block (by the receiver) '52': RejectReason.SPAM, # Spam Content (by the receiver) '53': RejectReason.OTHER, # Prohibited Attachment (by the receiver) '54': RejectReason.BLOCKED, # Relaying Denied (by the receiver) '60': (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation '70': RejectReason.BOUNCED, # Transient Failure '80': (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe '90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe '100': (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response } def esp_to_anymail_event(self, event_class, event, raw_event): if event_class == 'relay_message': # This is an inbound event raise AnymailConfigurationError( "You seem to have set SparkPost's *inbound* relay webhook URL " "to Anymail's SparkPost *tracking* webhook URL.") event_type = self.event_types.get(event['type'], EventType.UNKNOWN) try: timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=utc) except (KeyError, TypeError, ValueError): timestamp = None try: tag = event['campaign_id'] # not 'rcpt_tags' -- those don't come from sending a message tags = [tag] if tag else None except KeyError: tags = [] try: reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER) try: # unpack (RejectReason, EventType) for reasons that change our event type reject_reason, event_type = reject_reason except ValueError: pass except KeyError: reject_reason = None # no bounce_class return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=event.get('transmission_id', None), # not 'message_id' -- see SparkPost backend event_id=event.get('event_id', None), recipient=event.get('raw_rcpt_to', None), # preserves email case (vs. 'rcpt_to') reject_reason=reject_reason, mta_response=event.get('raw_reason', None), # description=???, tags=tags, metadata=event.get('rcpt_meta', None) or {}, # message + recipient metadata click_url=event.get('target_link_url', None), user_agent=event.get('user_agent', None), esp_event=raw_event, ) class SparkPostInboundWebhookView(SparkPostBaseWebhookView): """Handler for SparkPost inbound relay webhook""" signal = inbound def esp_to_anymail_event(self, event_class, event, raw_event): if event_class != 'relay_message': # This is not an inbound event raise AnymailConfigurationError( "You seem to have set SparkPost's *tracking* webhook URL " "to Anymail's SparkPost *inbound* relay webhook URL.") if event['protocol'] != 'smtp': raise AnymailConfigurationError( "You cannot use Anymail's webhooks for SparkPost '{protocol}' relay events. " "Anymail only handles the 'smtp' protocol".format(protocol=event['protocol'])) raw_mime = event['content']['email_rfc822'] if event['content']['email_rfc822_is_base64']: raw_mime = b64decode(raw_mime).decode('utf-8') message = AnymailInboundMessage.parse_raw_mime(raw_mime) message.envelope_sender = event.get('msg_from', None) message.envelope_recipient = event.get('rcpt_to', None) return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # SparkPost does not provide a relay event timestamp event_id=None, # SparkPost does not provide an idempotent id for relay events esp_event=raw_event, message=message, ) django-anymail-1.4/AUTHORS.txt0000644000076500000240000000056513236203646017110 0ustar medmundsstaff00000000000000Anymail ======= Mike Edmunds Calvin Jeong Peter Wu Charlie DeTar Jonathan Baugh Anymail was forked from Djrill, which included contributions from: Kenneth Love Chris Jones Mike Edmunds ArnaudF Théo Crevon Rafael E. Belliard Jared Morse peillis José Padilla Jens Alm Eric Hennings Michael Hobbs Sameer Al-Sakran Kyle Gibson Wes Winham nikolay-saskovets William Hector django-anymail-1.4/django_anymail.egg-info/0000755000076500000240000000000013237125745021666 5ustar medmundsstaff00000000000000django-anymail-1.4/django_anymail.egg-info/dependency_links.txt0000644000076500000240000000000113237125744025733 0ustar medmundsstaff00000000000000 django-anymail-1.4/django_anymail.egg-info/not-zip-safe0000644000076500000240000000000112670172436024113 0ustar medmundsstaff00000000000000 django-anymail-1.4/django_anymail.egg-info/PKG-INFO0000644000076500000240000001760413237125744022772 0ustar medmundsstaff00000000000000Metadata-Version: 1.1 Name: django-anymail Version: 1.4 Summary: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and other transactional ESPs Home-page: https://github.com/anymail/django-anymail Author: Mike Edmunds Author-email: medmunds@gmail.com License: BSD License Description-Content-Type: UNKNOWN Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and more =========================================================================================== .. This README is reused in multiple places: * Github: project page, exactly as it appears here * Docs: shared-intro section gets included in docs/index.rst quickstart section gets included in docs/quickstart.rst * PyPI: project page (via setup.py long_description), with several edits to freeze it to the specific PyPI release (see long_description_from_readme in setup.py) You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. .. default-role:: literal .. _shared-intro: .. This shared-intro section is also included in docs/index.rst Anymail integrates several transactional email service providers (ESPs) into Django, with a consistent API that lets you use ESP-added features without locking your code to a particular ESP. It currently fully supports Mailgun, Mailjet, Postmark, SendGrid, and SparkPost, and has limited support for Mandrill. Anymail normalizes ESP functionality so it "just works" with Django's built-in `django.core.mail` package. It includes: * Support for HTML, attachments, extra headers, and other features of `Django's built-in email `_ * Extensions that make it easy to use extra ESP functionality, like tags, metadata, and tracking, with code that's portable between ESPs * Simplified inline images for HTML email * Normalized sent-message status and tracking notification, by connecting your ESP's webhooks to Django signals * "Batch transactional" sends using your ESP's merge and template features * Inbound message support, to receive email through your ESP's webhooks, with simplified, portable access to attachments and other inbound content Anymail is released under the BSD license. It is extensively tested against Django 1.8--2.0 (including Python 2.7, Python 3 and PyPy). Anymail releases follow `semantic versioning `_. .. END shared-intro .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.4 :target: https://travis-ci.org/anymail/django-anymail :alt: build status on Travis-CI .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.4 :target: https://anymail.readthedocs.io/en/v1.4/ :alt: documentation on ReadTheDocs **Resources** * Full documentation: https://anymail.readthedocs.io/en/v1.4/ * Package on PyPI: https://pypi.python.org/pypi/django-anymail * Project on Github: https://github.com/anymail/django-anymail * Changelog: https://github.com/anymail/django-anymail/releases Anymail 1-2-3 ------------- .. _quickstart: .. This quickstart section is also included in docs/quickstart.rst Here's how to send a message. This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid or SparkPost or any other supported ESP where you see "mailgun": 1. Install Anymail from PyPI: .. code-block:: console $ pip install django-anymail[mailgun] (The `[mailgun]` part installs any additional packages needed for that ESP. Mailgun doesn't have any, but some other ESPs do.) 2. Edit your project's ``settings.py``: .. code-block:: python INSTALLED_APPS = [ # ... "anymail", # ... ] ANYMAIL = { # (exact settings here depend on your ESP...) "MAILGUN_API_KEY": "", "MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed } EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or... DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings 3. Now the regular `Django email functions `_ will send through your chosen ESP: .. code-block:: python from django.core.mail import send_mail send_mail("It works!", "This will get sent through Mailgun", "Anymail Sender ", ["to@example.com"]) You could send an HTML message, complete with an inline image, custom tags and metadata: .. code-block:: python from django.core.mail import EmailMultiAlternatives from anymail.message import attach_inline_image_file msg = EmailMultiAlternatives( subject="Please activate your account", body="Click to activate your account: http://example.com/activate", from_email="Example ", to=["New User ", "account.manager@example.com"], reply_to=["Helpdesk "]) # Include an inline image in the html: logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") html = """Logo

Please activate your account

""".format(logo_cid=logo_cid) msg.attach_alternative(html, "text/html") # Optional Anymail extensions: msg.metadata = {"user_id": "8675309", "experiment_variation": 1} msg.tags = ["activation", "onboarding"] msg.track_clicks = True # Send it: msg.send() .. END quickstart See the `full documentation `_ for more features and options, including receiving messages and tracking sent message status. Keywords: django,email,email backend,ESP,transactional mail,mailgun,mailjet,mandrill,postmark,sendgrid Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: License :: OSI Approved :: BSD License Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Framework :: Django Classifier: Environment :: Web Environment django-anymail-1.4/django_anymail.egg-info/requires.txt0000644000076500000240000000016113237125744024263 0ustar medmundsstaff00000000000000django>=1.8 requests>=2.4.3 six [mailgun] [mailjet] [mandrill] [postmark] [sendgrid] [sparkpost] sparkpost django-anymail-1.4/django_anymail.egg-info/SOURCES.txt0000644000076500000240000000201413237125744023546 0ustar medmundsstaff00000000000000AUTHORS.txt LICENSE MANIFEST.in README.rst setup.py anymail/__init__.py anymail/_version.py anymail/apps.py anymail/checks.py anymail/exceptions.py anymail/inbound.py anymail/message.py anymail/signals.py anymail/urls.py anymail/utils.py anymail/backends/__init__.py anymail/backends/base.py anymail/backends/base_requests.py anymail/backends/console.py anymail/backends/mailgun.py anymail/backends/mailjet.py anymail/backends/mandrill.py anymail/backends/postmark.py anymail/backends/sendgrid.py anymail/backends/sendgrid_v2.py anymail/backends/sparkpost.py anymail/backends/test.py anymail/webhooks/__init__.py anymail/webhooks/base.py anymail/webhooks/mailgun.py anymail/webhooks/mailjet.py anymail/webhooks/mandrill.py anymail/webhooks/postmark.py anymail/webhooks/sendgrid.py anymail/webhooks/sparkpost.py django_anymail.egg-info/PKG-INFO django_anymail.egg-info/SOURCES.txt django_anymail.egg-info/dependency_links.txt django_anymail.egg-info/not-zip-safe django_anymail.egg-info/requires.txt django_anymail.egg-info/top_level.txtdjango-anymail-1.4/django_anymail.egg-info/top_level.txt0000644000076500000240000000001013237125744024406 0ustar medmundsstaff00000000000000anymail django-anymail-1.4/LICENSE0000644000076500000240000000303012664365436016227 0ustar medmundsstaff00000000000000[The BSD 3-Clause License] Copyright (c) Anymail Contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-anymail-1.4/MANIFEST.in0000644000076500000240000000010612674037400016745 0ustar medmundsstaff00000000000000include README.rst AUTHORS.txt LICENSE recursive-include anymail *.py django-anymail-1.4/PKG-INFO0000644000076500000240000001760413237125745016325 0ustar medmundsstaff00000000000000Metadata-Version: 1.1 Name: django-anymail Version: 1.4 Summary: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and other transactional ESPs Home-page: https://github.com/anymail/django-anymail Author: Mike Edmunds Author-email: medmunds@gmail.com License: BSD License Description-Content-Type: UNKNOWN Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and more =========================================================================================== .. This README is reused in multiple places: * Github: project page, exactly as it appears here * Docs: shared-intro section gets included in docs/index.rst quickstart section gets included in docs/quickstart.rst * PyPI: project page (via setup.py long_description), with several edits to freeze it to the specific PyPI release (see long_description_from_readme in setup.py) You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. .. default-role:: literal .. _shared-intro: .. This shared-intro section is also included in docs/index.rst Anymail integrates several transactional email service providers (ESPs) into Django, with a consistent API that lets you use ESP-added features without locking your code to a particular ESP. It currently fully supports Mailgun, Mailjet, Postmark, SendGrid, and SparkPost, and has limited support for Mandrill. Anymail normalizes ESP functionality so it "just works" with Django's built-in `django.core.mail` package. It includes: * Support for HTML, attachments, extra headers, and other features of `Django's built-in email `_ * Extensions that make it easy to use extra ESP functionality, like tags, metadata, and tracking, with code that's portable between ESPs * Simplified inline images for HTML email * Normalized sent-message status and tracking notification, by connecting your ESP's webhooks to Django signals * "Batch transactional" sends using your ESP's merge and template features * Inbound message support, to receive email through your ESP's webhooks, with simplified, portable access to attachments and other inbound content Anymail is released under the BSD license. It is extensively tested against Django 1.8--2.0 (including Python 2.7, Python 3 and PyPy). Anymail releases follow `semantic versioning `_. .. END shared-intro .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.4 :target: https://travis-ci.org/anymail/django-anymail :alt: build status on Travis-CI .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.4 :target: https://anymail.readthedocs.io/en/v1.4/ :alt: documentation on ReadTheDocs **Resources** * Full documentation: https://anymail.readthedocs.io/en/v1.4/ * Package on PyPI: https://pypi.python.org/pypi/django-anymail * Project on Github: https://github.com/anymail/django-anymail * Changelog: https://github.com/anymail/django-anymail/releases Anymail 1-2-3 ------------- .. _quickstart: .. This quickstart section is also included in docs/quickstart.rst Here's how to send a message. This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid or SparkPost or any other supported ESP where you see "mailgun": 1. Install Anymail from PyPI: .. code-block:: console $ pip install django-anymail[mailgun] (The `[mailgun]` part installs any additional packages needed for that ESP. Mailgun doesn't have any, but some other ESPs do.) 2. Edit your project's ``settings.py``: .. code-block:: python INSTALLED_APPS = [ # ... "anymail", # ... ] ANYMAIL = { # (exact settings here depend on your ESP...) "MAILGUN_API_KEY": "", "MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed } EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or... DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings 3. Now the regular `Django email functions `_ will send through your chosen ESP: .. code-block:: python from django.core.mail import send_mail send_mail("It works!", "This will get sent through Mailgun", "Anymail Sender ", ["to@example.com"]) You could send an HTML message, complete with an inline image, custom tags and metadata: .. code-block:: python from django.core.mail import EmailMultiAlternatives from anymail.message import attach_inline_image_file msg = EmailMultiAlternatives( subject="Please activate your account", body="Click to activate your account: http://example.com/activate", from_email="Example ", to=["New User ", "account.manager@example.com"], reply_to=["Helpdesk "]) # Include an inline image in the html: logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") html = """Logo

Please activate your account

""".format(logo_cid=logo_cid) msg.attach_alternative(html, "text/html") # Optional Anymail extensions: msg.metadata = {"user_id": "8675309", "experiment_variation": 1} msg.tags = ["activation", "onboarding"] msg.track_clicks = True # Send it: msg.send() .. END quickstart See the `full documentation `_ for more features and options, including receiving messages and tracking sent message status. Keywords: django,email,email backend,ESP,transactional mail,mailgun,mailjet,mandrill,postmark,sendgrid Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: License :: OSI Approved :: BSD License Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Framework :: Django Classifier: Environment :: Web Environment django-anymail-1.4/README.rst0000644000076500000240000001310513236203646016703 0ustar medmundsstaff00000000000000Anymail: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and more =========================================================================================== .. This README is reused in multiple places: * Github: project page, exactly as it appears here * Docs: shared-intro section gets included in docs/index.rst quickstart section gets included in docs/quickstart.rst * PyPI: project page (via setup.py long_description), with several edits to freeze it to the specific PyPI release (see long_description_from_readme in setup.py) You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. .. default-role:: literal .. _shared-intro: .. This shared-intro section is also included in docs/index.rst Anymail integrates several transactional email service providers (ESPs) into Django, with a consistent API that lets you use ESP-added features without locking your code to a particular ESP. It currently fully supports Mailgun, Mailjet, Postmark, SendGrid, and SparkPost, and has limited support for Mandrill. Anymail normalizes ESP functionality so it "just works" with Django's built-in `django.core.mail` package. It includes: * Support for HTML, attachments, extra headers, and other features of `Django's built-in email `_ * Extensions that make it easy to use extra ESP functionality, like tags, metadata, and tracking, with code that's portable between ESPs * Simplified inline images for HTML email * Normalized sent-message status and tracking notification, by connecting your ESP's webhooks to Django signals * "Batch transactional" sends using your ESP's merge and template features * Inbound message support, to receive email through your ESP's webhooks, with simplified, portable access to attachments and other inbound content Anymail is released under the BSD license. It is extensively tested against Django 1.8--2.0 (including Python 2.7, Python 3 and PyPy). Anymail releases follow `semantic versioning `_. .. END shared-intro .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=master :target: https://travis-ci.org/anymail/django-anymail :alt: build status on Travis-CI .. image:: https://readthedocs.org/projects/anymail/badge/?version=stable :target: https://anymail.readthedocs.io/en/stable/ :alt: documentation on ReadTheDocs **Resources** * Full documentation: https://anymail.readthedocs.io/en/stable/ * Package on PyPI: https://pypi.python.org/pypi/django-anymail * Project on Github: https://github.com/anymail/django-anymail * Changelog: https://github.com/anymail/django-anymail/releases Anymail 1-2-3 ------------- .. _quickstart: .. This quickstart section is also included in docs/quickstart.rst Here's how to send a message. This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid or SparkPost or any other supported ESP where you see "mailgun": 1. Install Anymail from PyPI: .. code-block:: console $ pip install django-anymail[mailgun] (The `[mailgun]` part installs any additional packages needed for that ESP. Mailgun doesn't have any, but some other ESPs do.) 2. Edit your project's ``settings.py``: .. code-block:: python INSTALLED_APPS = [ # ... "anymail", # ... ] ANYMAIL = { # (exact settings here depend on your ESP...) "MAILGUN_API_KEY": "", "MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed } EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or... DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings 3. Now the regular `Django email functions `_ will send through your chosen ESP: .. code-block:: python from django.core.mail import send_mail send_mail("It works!", "This will get sent through Mailgun", "Anymail Sender ", ["to@example.com"]) You could send an HTML message, complete with an inline image, custom tags and metadata: .. code-block:: python from django.core.mail import EmailMultiAlternatives from anymail.message import attach_inline_image_file msg = EmailMultiAlternatives( subject="Please activate your account", body="Click to activate your account: http://example.com/activate", from_email="Example ", to=["New User ", "account.manager@example.com"], reply_to=["Helpdesk "]) # Include an inline image in the html: logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") html = """Logo

Please activate your account

""".format(logo_cid=logo_cid) msg.attach_alternative(html, "text/html") # Optional Anymail extensions: msg.metadata = {"user_id": "8675309", "experiment_variation": 1} msg.tags = ["activation", "onboarding"] msg.track_clicks = True # Send it: msg.send() .. END quickstart See the `full documentation `_ for more features and options, including receiving messages and tracking sent message status. django-anymail-1.4/setup.cfg0000644000076500000240000000004613237125745017041 0ustar medmundsstaff00000000000000[egg_info] tag_build = tag_date = 0 django-anymail-1.4/setup.py0000644000076500000240000000551313236203646016732 0ustar medmundsstaff00000000000000from setuptools import setup import re # define __version__ and __minor_version__ from anymail/_version.py, # but without importing from anymail (which would break setup) __version__ = "UNSET" __minor_version__ = "UNSET" with open("anymail/_version.py") as f: code = compile(f.read(), "anymail/_version.py", 'exec') exec(code) def long_description_from_readme(rst): # Freeze external links (on PyPI) to refer to this X.Y or X.Y.Z tag. # (This relies on tagging releases with 'vX.Y' or 'vX.Y.Z' in GitHub.) release = 'v%s' % __version__ # vX.Y or vX.Y.Z rst = re.sub(r'(?<=branch=)master' # Travis build status: branch=master --> branch=vX.Y.Z r'|(?<=/)stable' # ReadTheDocs links: /stable --> /vX.Y.Z r'|(?<=version=)stable', # ReadTheDocs badge: version=stable --> version=vX.Y.Z release, rst) # (?<=...) is "positive lookbehind": must be there, but won't get replaced return rst with open('README.rst') as f: long_description = long_description_from_readme(f.read()) setup( name="django-anymail", version=__version__, description='Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost ' 'and other transactional ESPs', keywords="django, email, email backend, ESP, transactional mail, mailgun, mailjet, mandrill, postmark, sendgrid", author="Mike Edmunds ", author_email="medmunds@gmail.com", url="https://github.com/anymail/django-anymail", license="BSD License", packages=["anymail"], zip_safe=False, install_requires=["django>=1.8", "requests>=2.4.3", "six"], extras_require={ # This can be used if particular backends have unique dependencies # (e.g., AWS-SES would want boto). # For simplicity, requests is included in the base requirements. "mailgun": [], "mailjet": [], "mandrill": [], "postmark": [], "sendgrid": [], "sparkpost": ["sparkpost"], }, include_package_data=True, test_suite="runtests.runtests", tests_require=["mock", "sparkpost"], classifiers=[ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", "Framework :: Django", "Environment :: Web Environment", ], long_description=long_description, )