Codebase list django-anymail / 1633802
Imported Upstream version 5.0 Scott Kitterman 5 years ago
19 changed file(s) with 522 addition(s) and 462 deletion(s). Raw diff Collapse all Expand all
0 Metadata-Version: 1.2
0 Metadata-Version: 2.1
11 Name: django-anymail
2 Version: 3.0
2 Version: 5.0
33 Summary: Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs
44 Home-page: https://github.com/anymail/django-anymail
55 Author: Mike Edmunds and Anymail contributors
66 Author-email: medmunds@gmail.com
77 License: BSD License
8 Project-URL: Documentation, https://anymail.readthedocs.io/en/v3.0/
8 Project-URL: Documentation, https://anymail.readthedocs.io/en/v5.0/
99 Project-URL: Source, https://github.com/anymail/django-anymail
10 Project-URL: Changelog, https://github.com/anymail/django-anymail/releases
10 Project-URL: Changelog, https://anymail.readthedocs.io/en/v5.0/changelog/
1111 Project-URL: Tracker, https://github.com/anymail/django-anymail/issues
12 Description-Content-Type: UNKNOWN
1312 Description: Anymail: Django email integration for transactional ESPs
1413 ========================================================
1514
4241 built-in `django.core.mail` package. It includes:
4342
4443 * Support for HTML, attachments, extra headers, and other features of
45 `Django's built-in email <https://docs.djangoproject.com/en/v3.0/topics/email/>`_
44 `Django's built-in email <https://docs.djangoproject.com/en/v5.0/topics/email/>`_
4645 * Extensions that make it easy to use extra ESP functionality, like tags, metadata,
4746 and tracking, with code that's portable between ESPs
4847 * Simplified inline images for HTML email
5352 with simplified, portable access to attachments and other inbound content
5453
5554 Anymail is released under the BSD license. It is extensively tested against
56 Django 1.8--2.1 (including Python 2.7, Python 3 and PyPy).
55 Django 1.11--2.1 (including Python 2.7, Python 3 and PyPy).
5756 Anymail releases follow `semantic versioning <http://semver.org/>`_.
5857
5958 .. END shared-intro
6059
61 .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v3.0
60 .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v5.0
6261 :target: https://travis-ci.org/anymail/django-anymail
6362 :alt: build status on Travis-CI
6463
65 .. image:: https://readthedocs.org/projects/anymail/badge/?version=v3.0
66 :target: https://anymail.readthedocs.io/en/v3.0/
64 .. image:: https://readthedocs.org/projects/anymail/badge/?version=v5.0
65 :target: https://anymail.readthedocs.io/en/v5.0/
6766 :alt: documentation on ReadTheDocs
6867
6968 **Resources**
7069
71 * Full documentation: https://anymail.readthedocs.io/en/v3.0/
72 * Package on PyPI: https://pypi.python.org/pypi/django-anymail
70 * Full documentation: https://anymail.readthedocs.io/en/v5.0/
71 * Package on PyPI: https://pypi.org/project/django-anymail/
7372 * Project on Github: https://github.com/anymail/django-anymail
74 * Changelog: https://github.com/anymail/django-anymail/releases
73 * Changelog: https://anymail.readthedocs.io/en/v5.0/changelog/
7574
7675
7776 Anymail 1-2-3
114113 DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings
115114
116115
117 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v3.0/topics/email/>`_
116 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v5.0/topics/email/>`_
118117 will send through your chosen ESP:
119118
120119 .. code-block:: python
158157 .. END quickstart
159158
160159
161 See the `full documentation <https://anymail.readthedocs.io/en/v3.0/>`_
160 See the `full documentation <https://anymail.readthedocs.io/en/v5.0/>`_
162161 for more features and options, including receiving messages and tracking
163162 sent message status.
164163
180179 Classifier: Topic :: Software Development :: Libraries :: Python Modules
181180 Classifier: Intended Audience :: Developers
182181 Classifier: Framework :: Django
183 Classifier: Framework :: Django :: 1.8
184 Classifier: Framework :: Django :: 1.9
185 Classifier: Framework :: Django :: 1.10
186182 Classifier: Framework :: Django :: 1.11
187183 Classifier: Framework :: Django :: 2.0
184 Classifier: Framework :: Django :: 2.1
188185 Classifier: Environment :: Web Environment
186 Provides-Extra: mandrill
187 Provides-Extra: sendinblue
188 Provides-Extra: sparkpost
189 Provides-Extra: mailgun
190 Provides-Extra: sendgrid
191 Provides-Extra: mailjet
192 Provides-Extra: amazon_ses
193 Provides-Extra: postmark
4040 with simplified, portable access to attachments and other inbound content
4141
4242 Anymail is released under the BSD license. It is extensively tested against
43 Django 1.8--2.1 (including Python 2.7, Python 3 and PyPy).
43 Django 1.11--2.1 (including Python 2.7, Python 3 and PyPy).
4444 Anymail releases follow `semantic versioning <http://semver.org/>`_.
4545
4646 .. END shared-intro
5656 **Resources**
5757
5858 * Full documentation: https://anymail.readthedocs.io/en/stable/
59 * Package on PyPI: https://pypi.python.org/pypi/django-anymail
59 * Package on PyPI: https://pypi.org/project/django-anymail/
6060 * Project on Github: https://github.com/anymail/django-anymail
61 * Changelog: https://github.com/anymail/django-anymail/releases
61 * Changelog: https://anymail.readthedocs.io/en/stable/changelog/
6262
6363
6464 Anymail 1-2-3
0 VERSION = (3, 0)
0 VERSION = (5, 0)
11 __version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN
22 __minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version"
0 from email.charset import Charset, QP
01 from email.header import Header
12 from email.mime.base import MIMEBase
3 from email.mime.text import MIMEText
24
35 from django.core.mail import BadHeaderError
46
128130 if HeaderBugWorkaround and "Subject" in self.mime_message:
129131 # (message.message() will have already checked subject for BadHeaderError)
130132 self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject))
133
134 # Work around an Amazon SES bug where, if all of:
135 # - the message body (text or html) contains non-ASCII characters
136 # - the body is sent with `Content-Transfer-Encoding: 8bit`
137 # (which is Django email's default for most non-ASCII bodies)
138 # - you are using an SES ConfigurationSet with open or click tracking enabled
139 # then SES replaces the non-ASCII characters with question marks as it rewrites
140 # the message to add tracking. Forcing `CTE: quoted-printable` avoids the problem.
141 # (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
142 for part in self.mime_message.walk():
143 if part.get_content_maintype() == "text" and part["Content-Transfer-Encoding"] == "8bit":
144 content = part.get_payload()
145 del part["Content-Transfer-Encoding"]
146 qp_charset = Charset(part.get_content_charset("us-ascii"))
147 qp_charset.body_encoding = QP
148 # (can't use part.set_payload, because SafeMIMEText can undo this workaround)
149 MIMEText.set_payload(part, content, charset=qp_charset)
131150
132151 def call_send_api(self, ses_client):
133152 self.params["RawMessage"] = {
2828 kwargs=kwargs, default=False)
2929 self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status',
3030 kwargs=kwargs, default=False)
31 self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output
32 kwargs=kwargs, default=False)
3133
3234 # Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings
3335 send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs
0 from __future__ import print_function
1
02 import requests
3 import six
14 from six.moves.urllib.parse import urljoin
25
36 from anymail.utils import get_anymail_setting
3134 self.session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format(
3235 esp=self.esp_name.lower(), version=__version__,
3336 orig=self.session.headers.get("User-Agent", ""))
37 if self.debug_api_requests:
38 self.session.hooks['response'].append(self._dump_api_request)
3439 return True
3540
3641 def close(self):
99104 email_message=message, payload=payload, response=response,
100105 backend=self)
101106
107 @staticmethod
108 def _dump_api_request(response, **kwargs):
109 """Print the request and response for debugging"""
110 # (This is not byte-for-byte, but a readable text representation that assumes
111 # UTF-8 encoding if encoded, and that omits the CR in CRLF line endings.
112 # If you need the raw bytes, configure HTTPConnection logging as shown
113 # in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
114 request = response.request # a PreparedRequest
115 print(u"\n===== Anymail API request")
116 print(u"{method} {url}\n{headers}".format(
117 method=request.method, url=request.url,
118 headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
119 for (header, value) in request.headers.items()),
120 ))
121 if request.body is not None:
122 body_text = (request.body if isinstance(request.body, six.text_type)
123 else request.body.decode("utf-8", errors="replace")
124 ).replace("\r\n", "\n")
125 print(body_text)
126 print(u"\n----- Response")
127 print(u"HTTP {status} {reason}\n{headers}\n{body}".format(
128 status=response.status_code, reason=response.reason,
129 headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
130 for (header, value) in response.headers.items()),
131 body=response.text, # Let Requests decode body content for us
132 ))
133
102134
103135 class RequestsPayload(BasePayload):
104136 """Abstract Payload for AnymailRequestsBackend"""
00 from datetime import datetime
1 from email.utils import encode_rfc2231
2
3 from requests import Request
14
25 from ..exceptions import AnymailRequestsAPIError, AnymailError
36 from ..message import AnymailRecipientStatus
7780 backend=self.backend, email_message=self.message, payload=self)
7881 return "%s/messages" % self.sender_domain
7982
83 def get_request_params(self, api_url):
84 params = super(MailgunPayload, self).get_request_params(api_url)
85 non_ascii_filenames = [filename
86 for (field, (filename, content, mimetype)) in params["files"]
87 if filename is not None and not isascii(filename)]
88 if non_ascii_filenames:
89 # Workaround https://github.com/requests/requests/issues/4652:
90 # Mailgun expects RFC 7578 compliant multipart/form-data, and is confused
91 # by Requests/urllib3's improper use of RFC 2231 encoded filename parameters
92 # ("filename*=utf-8''...") in Content-Disposition headers.
93 # The workaround is to pre-generate the (non-compliant) form-data body, and
94 # replace 'filename*={RFC 2231 encoded}' with 'filename="{UTF-8 bytes}"'.
95 # Replace _only_ the filenames that will be problems (not all "filename*=...")
96 # to minimize potential side effects--e.g., in attached messages that might
97 # have their own attachments with (correctly) RFC 2231 encoded filenames.
98 prepared = Request(**params).prepare()
99 form_data = prepared.body # bytes
100 for filename in non_ascii_filenames: # text
101 rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2)
102 filename if isinstance(filename, str) else filename.encode("utf-8"),
103 charset="utf-8")
104 form_data = form_data.replace(
105 b'filename*=' + rfc2231_filename.encode("utf-8"),
106 b'filename="' + filename.encode("utf-8") + b'"')
107 params["data"] = form_data
108 params["headers"]["Content-Type"] = prepared.headers["Content-Type"] # multipart/form-data; boundary=...
109 params["files"] = None # these are now in the form_data body
110 return params
111
80112 def serialize_data(self):
81113 self.populate_recipient_variables()
82114 return self.data
112144 def init_payload(self):
113145 self.data = {} # {field: [multiple, values]}
114146 self.files = [] # [(field, multiple), (field, values)]
147 self.headers = {}
115148
116149 def set_from_email_list(self, emails):
117150 # Mailgun supports multiple From email addresses
154187 if attachment.inline:
155188 field = "inline"
156189 name = attachment.cid
190 if not name:
191 self.unsupported_feature("inline attachments without Content-ID")
157192 else:
158193 field = "attachment"
159194 name = attachment.name
195 if not name:
196 self.unsupported_feature("attachments without filenames")
160197 self.files.append(
161198 (field, (name, attachment.content, attachment.mimetype))
162199 )
203240 # Allow override of sender_domain via esp_extra
204241 # (but pop it out of params to send to Mailgun)
205242 self.sender_domain = self.data.pop("sender_domain", self.sender_domain)
243
244
245 def isascii(s):
246 """Returns True if str s is entirely ASCII characters.
247
248 (Compare to Python 3.7 `str.isascii()`.)
249 """
250 try:
251 s.encode("ascii")
252 except UnicodeEncodeError:
253 return False
254 return True
11
22 from ..exceptions import AnymailRequestsAPIError
33 from ..message import AnymailRecipientStatus
4 from ..utils import get_anymail_setting
4 from ..utils import get_anymail_setting, parse_address_list
55
66 from .base_requests import AnymailRequestsBackend, RequestsPayload
77
3232 super(EmailBackend, self).raise_for_status(response, payload, message)
3333
3434 def parse_recipient_status(self, response, payload, message):
35 # default to "unknown" status for each recipient, unless/until we find otherwise
36 unknown_status = AnymailRecipientStatus(message_id=None, status='unknown')
37 recipient_status = {to.addr_spec: unknown_status for to in payload.to_emails}
38
3539 parsed_response = self.deserialize_json_response(response, payload, message)
36 try:
37 error_code = parsed_response["ErrorCode"]
38 msg = parsed_response["Message"]
39 except (KeyError, TypeError):
40 raise AnymailRequestsAPIError("Invalid Postmark API response format",
41 email_message=message, payload=payload, response=response,
42 backend=self)
43
44 message_id = parsed_response.get("MessageID", None)
45 rejected_emails = []
46
47 if error_code == 300: # Invalid email request
48 # Either the From address or at least one recipient was invalid. Email not sent.
49 if "'From' address" in msg:
50 # Normal error
40 if not isinstance(parsed_response, list):
41 # non-batch calls return a single response object
42 parsed_response = [parsed_response]
43
44 for one_response in parsed_response:
45 try:
46 # these fields should always be present
47 error_code = one_response["ErrorCode"]
48 msg = one_response["Message"]
49 except (KeyError, TypeError):
50 raise AnymailRequestsAPIError("Invalid Postmark API response format",
51 email_message=message, payload=payload, response=response,
52 backend=self)
53
54 if error_code == 0:
55 # At least partial success, and (some) email was sent.
56 try:
57 to_header = one_response["To"]
58 message_id = one_response["MessageID"]
59 except KeyError:
60 raise AnymailRequestsAPIError("Invalid Postmark API success response format",
61 email_message=message, payload=payload,
62 response=response, backend=self)
63 for to in parse_address_list(to_header):
64 recipient_status[to.addr_spec.lower()] = AnymailRecipientStatus(
65 message_id=message_id, status='sent')
66 # Sadly, have to parse human-readable message to figure out if everyone got it:
67 # "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
68 # Inactive recipients are ones that have generated a hard bounce or a spam complaint."
69 reject_addr_specs = self._addr_specs_from_error_msg(
70 msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
71 for reject_addr_spec in reject_addr_specs:
72 recipient_status[reject_addr_spec] = AnymailRecipientStatus(
73 message_id=None, status='rejected')
74
75 elif error_code == 300: # Invalid email request
76 # Either the From address or at least one recipient was invalid. Email not sent.
77 # response["To"] is not populated for this error; must examine response["Message"]:
78 # "Invalid 'To' address: '{addr_spec}'."
79 # "Error parsing 'To': Illegal email domain '{domain}' in address '{addr_spec}'."
80 # "Error parsing 'To': Illegal email address '{addr_spec}'. It must contain the '@' symbol."
81 # "Invalid 'From' address: '{email_address}'."
82 if "'From' address" in msg:
83 # Normal error
84 raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
85 backend=self)
86 else:
87 # Use AnymailRecipientsRefused logic
88 invalid_addr_specs = self._addr_specs_from_error_msg(msg, r"address:?\s*'(.*)'")
89 for invalid_addr_spec in invalid_addr_specs:
90 recipient_status[invalid_addr_spec] = AnymailRecipientStatus(
91 message_id=None, status='invalid')
92
93 elif error_code == 406: # Inactive recipient
94 # All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
95 # response["To"] is not populated for this error; must examine response["Message"]:
96 # "You tried to send to a recipient that has been marked as inactive.\n
97 # Found inactive addresses: {addr_spec, ...}.\n
98 # Inactive recipients are ones that have generated a hard bounce or a spam complaint. "
99 reject_addr_specs = self._addr_specs_from_error_msg(
100 msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
101 for reject_addr_spec in reject_addr_specs:
102 recipient_status[reject_addr_spec] = AnymailRecipientStatus(
103 message_id=None, status='rejected')
104
105 else: # Other error
51106 raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
52107 backend=self)
53 else:
54 # Use AnymailRecipientsRefused logic
55 default_status = 'invalid'
56 elif error_code == 406: # Inactive recipient
57 # All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
58 default_status = 'rejected'
59 elif error_code == 0:
60 # At least partial success, and email was sent.
61 # Sadly, have to parse human-readable message to figure out if everyone got it.
62 default_status = 'sent'
63 rejected_emails = self.parse_inactive_recipients(msg)
64 else:
65 raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
66 backend=self)
67
68 return {
69 recipient.addr_spec: AnymailRecipientStatus(
70 message_id=message_id,
71 status=('rejected' if recipient.addr_spec.lower() in rejected_emails
72 else default_status)
73 )
74 for recipient in payload.all_recipients
75 }
76
77 def parse_inactive_recipients(self, msg):
78 """Return a list of 'inactive' email addresses from a Postmark "OK" response
79
80 :param str msg: the "Message" from the Postmark API response
108
109 return recipient_status
110
111 @staticmethod
112 def _addr_specs_from_error_msg(error_msg, pattern):
113 """Extract a list of email addr_specs from Postmark error_msg.
114
115 pattern must be a re whose first group matches a comma-separated
116 list of addr_specs in the message
81117 """
82 # Example msg with inactive recipients:
83 # "Message OK, but will not deliver to these inactive addresses: one@xample.com, two@example.com."
84 # " Inactive recipients are ones that have generated a hard bounce or a spam complaint."
85 # Example msg with everything OK: "OK"
86 match = re.search(r'inactive addresses:\s*(.*)\.\s*Inactive recipients', msg)
118 match = re.search(pattern, error_msg, re.MULTILINE)
87119 if match:
88120 emails = match.group(1) # "one@xample.com, two@example.com"
89121 return [email.strip().lower() for email in emails.split(',')]
100132 # 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
101133 }
102134 self.server_token = backend.server_token # added to headers later, so esp_extra can override
103 self.all_recipients = [] # used for backend.parse_recipient_status
135 self.to_emails = []
136 self.merge_data = None
104137 super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
105138
106139 def get_api_endpoint(self):
107 if 'TemplateId' in self.data or 'TemplateModel' in self.data:
108 # This is the one Postmark API documented to have a trailing slash. (Typo?)
109 return "email/withTemplate/"
140 batch_send = self.merge_data is not None and len(self.to_emails) > 1
141 if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
142 if batch_send:
143 return "email/batchWithTemplates"
144 else:
145 # This is the one Postmark API documented to have a trailing slash. (Typo?)
146 return "email/withTemplate/"
110147 else:
111 return "email"
148 if batch_send:
149 return "email/batch"
150 else:
151 return "email"
112152
113153 def get_request_params(self, api_url):
114154 params = super(PostmarkPayload, self).get_request_params(api_url)
116156 return params
117157
118158 def serialize_data(self):
119 return self.serialize_json(self.data)
159 data = self.data
160 api_endpoint = self.get_api_endpoint()
161 if api_endpoint == "email/batchWithTemplates":
162 data = {"Messages": [self.data_for_recipient(to) for to in self.to_emails]}
163 elif api_endpoint == "email/batch":
164 data = [self.data_for_recipient(to) for to in self.to_emails]
165 return self.serialize_json(data)
166
167 def data_for_recipient(self, to):
168 data = self.data.copy()
169 data["To"] = to.address
170 if self.merge_data and to.addr_spec in self.merge_data:
171 recipient_data = self.merge_data[to.addr_spec]
172 if "TemplateModel" in data:
173 # merge recipient_data into merge_global_data
174 data["TemplateModel"] = data["TemplateModel"].copy()
175 data["TemplateModel"].update(recipient_data)
176 else:
177 data["TemplateModel"] = recipient_data
178 return data
120179
121180 #
122181 # Payload construction
135194 if emails:
136195 field = recipient_type.capitalize()
137196 self.data[field] = ', '.join([email.address for email in emails])
138 self.all_recipients += emails # used for backend.parse_recipient_status
197 if recipient_type == "to":
198 self.to_emails = emails
139199
140200 def set_subject(self, subject):
141201 self.data["Subject"] = subject
177237 self.make_attachment(attachment) for attachment in attachments
178238 ]
179239
180 # Postmark doesn't support metadata
181 # def set_metadata(self, metadata):
240 def set_metadata(self, metadata):
241 self.data["Metadata"] = metadata
182242
183243 # Postmark doesn't support delayed sending
184244 # def set_send_at(self, send_at):
196256 self.data["TrackOpens"] = track_opens
197257
198258 def set_template_id(self, template_id):
199 self.data["TemplateId"] = template_id
200
201 # merge_data: Postmark doesn't support per-recipient substitutions
259 try:
260 self.data["TemplateId"] = int(template_id)
261 except ValueError:
262 self.data["TemplateAlias"] = template_id
263
264 # Subject, TextBody, and HtmlBody aren't allowed with TemplateId;
265 # delete Django default subject and body empty strings:
266 for field in ("Subject", "TextBody", "HtmlBody"):
267 if field in self.data and not self.data[field]:
268 del self.data[field]
269
270 def set_merge_data(self, merge_data):
271 # late-bind
272 self.merge_data = merge_data
202273
203274 def set_merge_global_data(self, merge_global_data):
204275 self.data["TemplateModel"] = merge_global_data
00 import uuid
1 import warnings
12 from email.utils import quote as rfc822_quote
2 import warnings
33
44 from requests.structures import CaseInsensitiveDict
55
66 from .base_requests import AnymailRequestsBackend, RequestsPayload
77 from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
88 from ..message import AnymailRecipientStatus
9 from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting, timestamp, update_deep
9 from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep
1010
1111
1212 class EmailBackend(AnymailRequestsBackend):
2525 password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
2626 if username or password:
2727 raise AnymailConfigurationError(
28 "SendGrid v3 API doesn't support username/password auth; Please change to API key.\n"
29 "(For legacy v2 API, use anymail.backends.sendgrid_v2.EmailBackend.)")
28 "SendGrid v3 API doesn't support username/password auth; Please change to API key.")
3029
3130 self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
3231
7271 self.all_recipients = [] # used for backend.parse_recipient_status
7372 self.generate_message_id = backend.generate_message_id
7473 self.workaround_name_quote_bug = backend.workaround_name_quote_bug
74 self.use_dynamic_template = False # how to represent merge_data
7575 self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
7676 self.merge_field_format = backend.merge_field_format
7777 self.merge_data = None # late-bound per-recipient data
113113 self.data.setdefault("custom_args", {})["anymail_id"] = self.message_id
114114
115115 def build_merge_data(self):
116 if self.use_dynamic_template:
117 self.build_merge_data_dynamic()
118 else:
119 self.build_merge_data_legacy()
120
121 def build_merge_data_dynamic(self):
122 """Set personalizations[...]['dynamic_template_data']"""
123 if self.merge_global_data is not None:
124 assert len(self.data["personalizations"]) == 1
125 self.data["personalizations"][0].setdefault(
126 "dynamic_template_data", {}).update(self.merge_global_data)
127
128 if self.merge_data is not None:
129 # Burst apart each to-email in personalizations[0] into a separate
130 # personalization, and add merge_data for that recipient
131 assert len(self.data["personalizations"]) == 1
132 base_personalizations = self.data["personalizations"].pop()
133 to_list = base_personalizations.pop("to") # {email, name?} for each message.to
134 for recipient in to_list:
135 personalization = base_personalizations.copy() # captures cc, bcc, merge_global_data, esp_extra
136 personalization["to"] = [recipient]
137 try:
138 recipient_data = self.merge_data[recipient["email"]]
139 except KeyError:
140 pass # no merge_data for this recipient
141 else:
142 if "dynamic_template_data" in personalization:
143 # merge per-recipient data into (copy of) merge_global_data
144 personalization["dynamic_template_data"] = personalization["dynamic_template_data"].copy()
145 personalization["dynamic_template_data"].update(recipient_data)
146 else:
147 personalization["dynamic_template_data"] = recipient_data
148 self.data["personalizations"].append(personalization)
149
150 def build_merge_data_legacy(self):
116151 """Set personalizations[...]['substitutions'] and data['sections']"""
117152 merge_field_format = self.merge_field_format or '{}'
118153
135170 pass # no merge_data for this recipient
136171 self.data["personalizations"].append(personalization)
137172
138 if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
173 if self.merge_field_format is None and len(all_fields) and all(field.isalnum() for field in all_fields):
139174 warnings.warn(
140175 "Your SendGrid merge fields don't seem to have delimiters, "
141176 "which can cause unexpected results with Anymail's merge_data. "
291326
292327 def set_template_id(self, template_id):
293328 self.data["template_id"] = template_id
329 try:
330 self.use_dynamic_template = template_id.startswith("d-")
331 except AttributeError:
332 pass
294333
295334 def set_merge_data(self, merge_data):
296 # Becomes personalizations[...]['substitutions'] in build_merge_data,
297 # after we know recipients and merge_field_format.
335 # Becomes personalizations[...]['dynamic_template_data']
336 # or personalizations[...]['substitutions'] in build_merge_data,
337 # after we know recipients, template type, and merge_field_format.
298338 self.merge_data = merge_data
299339
300340 def set_merge_global_data(self, merge_global_data):
301 # Becomes data['section'] in build_merge_data, after we know merge_field_format.
341 # Becomes personalizations[...]['dynamic_template_data']
342 # or data['section'] in build_merge_data, after we know
343 # template type and merge_field_format.
302344 self.merge_global_data = merge_global_data
303345
304346 def set_esp_extra(self, extra):
305347 self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
348 self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template)
349 if isinstance(extra.get("personalizations", None), Mapping):
350 # merge personalizations *dict* into other message personalizations
351 assert len(self.data["personalizations"]) == 1
352 self.data["personalizations"][0].update(extra.pop("personalizations"))
306353 if "x-smtpapi" in extra:
307354 raise AnymailConfigurationError(
308355 "You are attempting to use SendGrid v2 API-style x-smtpapi params "
309 "with the SendGrid v3 API. Please update your `esp_extra` to the new API, "
310 "or use 'anymail.backends.sendgrid_v2.EmailBackend' for the old API."
356 "with the SendGrid v3 API. Please update your `esp_extra` to the new API."
311357 )
312358 update_deep(self.data, extra)
+0
-301
anymail/backends/sendgrid_v2.py less more
0 import uuid
1 import warnings
2
3 from requests.structures import CaseInsensitiveDict
4
5 from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
6 from ..message import AnymailRecipientStatus
7 from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting, timestamp
8
9 from .base_requests import AnymailRequestsBackend, RequestsPayload
10
11
12 class EmailBackend(AnymailRequestsBackend):
13 """
14 SendGrid v2 API Email Backend (deprecated)
15 """
16
17 esp_name = "SendGrid"
18
19 def __init__(self, **kwargs):
20 """Init options from Django settings"""
21 # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
22 esp_name = self.esp_name
23 self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs,
24 default=None, allow_bare=True)
25 self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs,
26 default=None, allow_bare=True)
27 self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
28 default=None, allow_bare=True)
29 if self.api_key is None and (self.username is None or self.password is None):
30 raise AnymailConfigurationError(
31 "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and "
32 "SENDGRID_PASSWORD in your Django ANYMAIL settings."
33 )
34
35 self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
36 kwargs=kwargs, default=True)
37 self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
38 kwargs=kwargs, default=None)
39
40 # This is SendGrid's older Web API v2
41 api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
42 default="https://api.sendgrid.com/api/")
43 if not api_url.endswith("/"):
44 api_url += "/"
45 super(EmailBackend, self).__init__(api_url, **kwargs)
46
47 def build_message_payload(self, message, defaults):
48 return SendGridPayload(message, defaults, self)
49
50 def parse_recipient_status(self, response, payload, message):
51 parsed_response = self.deserialize_json_response(response, payload, message)
52 try:
53 sendgrid_message = parsed_response["message"]
54 except (KeyError, TypeError):
55 raise AnymailRequestsAPIError("Invalid SendGrid API response format",
56 email_message=message, payload=payload, response=response,
57 backend=self)
58 if sendgrid_message != "success":
59 errors = parsed_response.get("errors", [])
60 raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors),
61 email_message=message, payload=payload, response=response,
62 backend=self)
63 # Simulate a per-recipient status of "queued":
64 status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
65 return {recipient.addr_spec: status for recipient in payload.all_recipients}
66
67
68 class SendGridPayload(RequestsPayload):
69 """
70 SendGrid v2 API Mail Send payload
71 """
72
73 def __init__(self, message, defaults, backend, *args, **kwargs):
74 self.all_recipients = [] # used for backend.parse_recipient_status
75 self.generate_message_id = backend.generate_message_id
76 self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
77 self.smtpapi = {} # SendGrid x-smtpapi field
78 self.to_list = [] # needed for build_merge_data
79 self.merge_field_format = backend.merge_field_format
80 self.merge_data = None # late-bound per-recipient data
81 self.merge_global_data = None
82
83 http_headers = kwargs.pop('headers', {})
84 query_params = kwargs.pop('params', {})
85 if backend.api_key is not None:
86 http_headers['Authorization'] = 'Bearer %s' % backend.api_key
87 else:
88 query_params['api_user'] = backend.username
89 query_params['api_key'] = backend.password
90 super(SendGridPayload, self).__init__(message, defaults, backend,
91 params=query_params, headers=http_headers,
92 *args, **kwargs)
93
94 def get_api_endpoint(self):
95 return "mail.send.json"
96
97 def serialize_data(self):
98 """Performs any necessary serialization on self.data, and returns the result."""
99
100 if self.generate_message_id:
101 self.set_anymail_id()
102
103 self.build_merge_data()
104 if self.merge_data is not None:
105 # Move the 'to' recipients to smtpapi, so SG does batch send
106 # (else all recipients would see each other's emails).
107 # Regular 'to' must still be a valid email (even though "ignored")...
108 # we use the from_email as recommended by SG support
109 # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250)
110 self.smtpapi['to'] = [email.address for email in self.to_list]
111 self.data['to'] = [self.data['from']]
112 self.data['toname'] = [self.data.get('fromname', " ")]
113
114 # Serialize x-smtpapi to json:
115 if len(self.smtpapi) > 0:
116 # If esp_extra was also used to set x-smtpapi, need to merge it
117 if "x-smtpapi" in self.data:
118 esp_extra_smtpapi = self.data["x-smtpapi"]
119 for key, value in esp_extra_smtpapi.items():
120 if key == "filters":
121 # merge filters (else it's difficult to mix esp_extra with other features)
122 self.smtpapi.setdefault(key, {}).update(value)
123 else:
124 # all other keys replace any current value
125 self.smtpapi[key] = value
126 self.data["x-smtpapi"] = self.serialize_json(self.smtpapi)
127 elif "x-smtpapi" in self.data:
128 self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"])
129
130 # Serialize extra headers to json:
131 if self.data["headers"]:
132 self.data["headers"] = self.serialize_json(self.data["headers"])
133 else:
134 del self.data["headers"]
135
136 return self.data
137
138 def set_anymail_id(self):
139 """Ensure message has a known anymail_id for later event tracking"""
140
141 self.message_id = str(uuid.uuid4())
142 self.smtpapi.setdefault('unique_args', {})["anymail_id"] = self.message_id
143
144 def build_merge_data(self):
145 """Set smtpapi['sub'] and ['section']"""
146 if self.merge_data is not None:
147 # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format)
148 # to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...})
149 all_fields = set()
150 for recipient_data in self.merge_data.values():
151 all_fields = all_fields.union(recipient_data.keys())
152 recipients = [email.addr_spec for email in self.to_list]
153
154 if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
155 warnings.warn(
156 "Your SendGrid merge fields don't seem to have delimiters, "
157 "which can cause unexpected results with Anymail's merge_data. "
158 "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
159 AnymailWarning)
160
161 sub_field_fmt = self.merge_field_format or '{}'
162 sub_fields = {field: sub_field_fmt.format(field) for field in all_fields}
163
164 self.smtpapi['sub'] = {
165 # If field data is missing for recipient, use (formatted) field as the substitution.
166 # (This allows default to resolve from global "section" substitutions.)
167 sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field])
168 for recipient in recipients]
169 for field in all_fields
170 }
171
172 if self.merge_global_data is not None:
173 section_field_fmt = self.merge_field_format or '{}'
174 self.smtpapi['section'] = {
175 section_field_fmt.format(field): data
176 for field, data in self.merge_global_data.items()
177 }
178
179 #
180 # Payload construction
181 #
182
183 def init_payload(self):
184 self.data = {} # {field: [multiple, values]}
185 self.files = {}
186 self.data['headers'] = CaseInsensitiveDict() # headers keys are case-insensitive
187
188 def set_from_email(self, email):
189 self.data["from"] = email.addr_spec
190 if email.display_name:
191 self.data["fromname"] = email.display_name
192
193 def set_to(self, emails):
194 self.to_list = emails # track for later use by build_merge_data
195 self.set_recipients('to', emails)
196
197 def set_recipients(self, recipient_type, emails):
198 assert recipient_type in ["to", "cc", "bcc"]
199 if emails:
200 self.data[recipient_type] = [email.addr_spec for email in emails]
201 empty_name = " " # SendGrid API balks on complete empty name fields
202 self.data[recipient_type + "name"] = [email.display_name or empty_name for email in emails]
203 self.all_recipients += emails # used for backend.parse_recipient_status
204
205 def set_subject(self, subject):
206 self.data["subject"] = subject
207
208 def set_reply_to(self, emails):
209 # Note: SendGrid mangles the 'replyto' API param: it drops
210 # all but the last email in a multi-address replyto, and
211 # drops all the display names. [tested 2016-03-10]
212 #
213 # To avoid those quirks, we provide a fully-formed Reply-To
214 # in the custom headers, which makes it through intact.
215 if emails:
216 reply_to = ", ".join([email.address for email in emails])
217 self.data["headers"]["Reply-To"] = reply_to
218
219 def set_extra_headers(self, headers):
220 # SendGrid requires header values to be strings -- not integers.
221 # We'll stringify ints and floats; anything else is the caller's responsibility.
222 # (This field gets converted to json in self.serialize_data)
223 self.data["headers"].update({
224 k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
225 for k, v in headers.items()
226 })
227
228 def set_text_body(self, body):
229 self.data["text"] = body
230
231 def set_html_body(self, body):
232 if "html" in self.data:
233 # second html body could show up through multiple alternatives, or html body + alternative
234 self.unsupported_feature("multiple html parts")
235 self.data["html"] = body
236
237 def add_attachment(self, attachment):
238 filename = attachment.name or ""
239 if attachment.inline:
240 filename = filename or attachment.cid # must have non-empty name for the cid matching
241 content_field = "content[%s]" % filename
242 self.data[content_field] = attachment.cid
243
244 files_field = "files[%s]" % filename
245 if files_field in self.files:
246 # It's possible SendGrid could actually handle this case (needs testing),
247 # but requests doesn't seem to accept a list of tuples for a files field.
248 # (See the Mailgun EmailBackend version for a different approach that might work.)
249 self.unsupported_feature(
250 "multiple attachments with the same filename ('%s')" % filename if filename
251 else "multiple unnamed attachments")
252
253 self.files[files_field] = (filename, attachment.content, attachment.mimetype)
254
255 def set_metadata(self, metadata):
256 self.smtpapi['unique_args'] = metadata
257
258 def set_send_at(self, send_at):
259 # Backend has converted pretty much everything to
260 # a datetime by here; SendGrid expects unix timestamp
261 self.smtpapi["send_at"] = int(timestamp(send_at)) # strip microseconds
262
263 def set_tags(self, tags):
264 self.smtpapi["category"] = tags
265
266 def add_filter(self, filter_name, setting, val):
267 self.smtpapi.setdefault('filters', {})\
268 .setdefault(filter_name, {})\
269 .setdefault('settings', {})[setting] = val
270
271 def set_track_clicks(self, track_clicks):
272 self.add_filter('clicktrack', 'enable', int(track_clicks))
273
274 def set_track_opens(self, track_opens):
275 # SendGrid's opentrack filter also supports a "replace"
276 # parameter, which Anymail doesn't offer directly.
277 # (You could add it through esp_extra.)
278 self.add_filter('opentrack', 'enable', int(track_opens))
279
280 def set_template_id(self, template_id):
281 self.add_filter('templates', 'enable', 1)
282 self.add_filter('templates', 'template_id', template_id)
283 # Must ensure text and html are non-empty, or template parts won't render.
284 # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates
285 if not self.data.get("text", ""):
286 self.data["text"] = " "
287 if not self.data.get("html", ""):
288 self.data["html"] = " "
289
290 def set_merge_data(self, merge_data):
291 # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format.
292 self.merge_data = merge_data
293
294 def set_merge_global_data(self, merge_global_data):
295 # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format.
296 self.merge_global_data = merge_global_data
297
298 def set_esp_extra(self, extra):
299 self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format)
300 self.data.update(extra)
22 from .base import AnymailBaseBackend, BasePayload
33 from ..exceptions import AnymailAPIError
44 from ..message import AnymailRecipientStatus
5 from ..utils import get_anymail_setting
65
76
87 class EmailBackend(AnymailBaseBackend):
136135 def set_esp_extra(self, extra):
137136 # Merge extra into params
138137 self.params.update(extra)
139
140
141 class _EmailBackendWithRequiredSetting(EmailBackend):
142 """Test backend with a required setting `sample_setting`.
143
144 Intended only for internal use by Anymail settings tests.
145 """
146
147 def __init__(self, *args, **kwargs):
148 esp_name = self.esp_name
149 self.sample_setting = get_anymail_setting('sample_setting', esp_name=esp_name,
150 kwargs=kwargs, allow_bare=True)
151 super(_EmailBackendWithRequiredSetting, self).__init__(*args, **kwargs)
5858
5959 def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None):
6060 """Add inline image to an EmailMessage, and return its content id"""
61 if domain is None:
62 # Avoid defaulting to hostname that might end in '.com', because some ESPs
63 # use Content-ID as filename, and Gmail blocks filenames ending in '.com'.
64 domain = 'inline' # valid domain for a msgid; will never be a real TLD
6165 content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
6266 image = MIMEImage(content, subtype)
6367 image.add_header('Content-Disposition', 'inline', filename=filename)
00 import base64
11 import mimetypes
22 from base64 import b64encode
3 from collections import Mapping, MutableMapping
43 from datetime import datetime
54 from email.mime.base import MIMEBase
65 from email.utils import formatdate, getaddresses, unquote
1312 from django.utils.functional import Promise
1413 from django.utils.timezone import utc, get_fixed_timezone
1514 from six.moves.urllib.parse import urlsplit, urlunsplit
15
16 try:
17 from collections.abc import Mapping, MutableMapping # Python 3.3+
18 except ImportError:
19 from collections import Mapping, MutableMapping
1620
1721 from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
1822
286290 self.content = attachment.as_string().encode(self.encoding)
287291 self.mimetype = attachment.get_content_type()
288292
289 if get_content_disposition(attachment) == 'inline':
293 content_disposition = get_content_disposition(attachment)
294 if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
290295 self.inline = True
291296 self.content_id = attachment["Content-ID"] # probably including the <...>
292297 if self.content_id is not None:
66 from django.utils.timezone import utc
77
88 from .base import AnymailBaseWebhookView
9 from ..exceptions import AnymailWebhookValidationFailure
9 from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress
1010 from ..inbound import AnymailInboundMessage
1111 from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
12 from ..utils import get_anymail_setting, combine, querydict_getfirst
12 from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address
1313
1414
1515 class MailgunBaseWebhookView(AnymailBaseWebhookView):
2828
2929 def validate_request(self, request):
3030 super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
31 try:
32 # Must use the *last* value of these fields if there are conflicting merged user-variables.
33 # (Fortunately, Django QueryDict is specced to return the last value.)
34 token = request.POST['token']
35 timestamp = request.POST['timestamp']
36 signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
37 except KeyError:
38 raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
31 if request.content_type == "application/json":
32 # New-style webhook: json payload with separate signature block
33 try:
34 event = json.loads(request.body.decode('utf-8'))
35 signature_block = event['signature']
36 token = signature_block['token']
37 timestamp = signature_block['timestamp']
38 signature = signature_block['signature']
39 except (KeyError, ValueError, UnicodeDecodeError) as err:
40 raise AnymailWebhookValidationFailure(
41 "Mailgun webhook called with invalid payload format",
42 raised_from=err)
43 else:
44 # Legacy webhook: signature fields are interspersed with other POST data
45 try:
46 # Must use the *last* value of these fields if there are conflicting merged user-variables.
47 # (Fortunately, Django QueryDict is specced to return the last value.)
48 token = request.POST['token']
49 timestamp = request.POST['timestamp']
50 signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
51 except KeyError:
52 raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
53
3954 expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
4055 digestmod=hashlib.sha256).hexdigest()
4156 if not constant_time_compare(signature, expected_signature):
4762
4863 signal = tracking
4964
65 def parse_events(self, request):
66 if request.content_type == "application/json":
67 esp_event = json.loads(request.body.decode('utf-8'))
68 return [self.esp_to_anymail_event(esp_event)]
69 else:
70 return [self.mailgun_legacy_to_anymail_event(request.POST)]
71
5072 event_types = {
73 # Map Mailgun event: Anymail normalized type
74 'accepted': EventType.QUEUED, # not delivered to webhooks (8/2018)
75 'rejected': EventType.REJECTED,
76 'delivered': EventType.DELIVERED,
77 'failed': EventType.BOUNCED,
78 'opened': EventType.OPENED,
79 'clicked': EventType.CLICKED,
80 'unsubscribed': EventType.UNSUBSCRIBED,
81 'complained': EventType.COMPLAINED,
82 }
83
84 reject_reasons = {
85 # Map Mailgun event_data.reason: Anymail normalized RejectReason
86 # (these appear in webhook doc examples, but aren't actually documented anywhere)
87 "bounce": RejectReason.BOUNCED,
88 "suppress-bounce": RejectReason.BOUNCED,
89 "generic": RejectReason.OTHER, # ??? appears to be used for any temporary failure?
90 }
91
92 severities = {
93 # Remap some event types based on "severity" payload field
94 (EventType.BOUNCED, 'temporary'): EventType.DEFERRED
95 }
96
97 def esp_to_anymail_event(self, esp_event):
98 event_data = esp_event.get('event-data', {})
99
100 event_type = self.event_types.get(event_data['event'], EventType.UNKNOWN)
101
102 event_type = self.severities.get((EventType.BOUNCED, event_data.get('severity')), event_type)
103
104 # Use signature.token for event_id, rather than event_data.id,
105 # because the latter is only "guaranteed to be unique within a day".
106 event_id = esp_event.get('signature', {}).get('token')
107
108 recipient = event_data.get('recipient')
109
110 try:
111 timestamp = datetime.fromtimestamp(float(event_data['timestamp']), tz=utc)
112 except KeyError:
113 timestamp = None
114
115 try:
116 message_id = event_data['message']['headers']['message-id']
117 except KeyError:
118 message_id = None
119 if message_id and not message_id.startswith('<'):
120 message_id = "<{}>".format(message_id)
121
122 metadata = event_data.get('user-variables', {})
123 tags = event_data.get('tags', [])
124
125 try:
126 delivery_status = event_data['delivery-status']
127 except KeyError:
128 description = None
129 mta_response = None
130 else:
131 description = delivery_status.get('description')
132 mta_response = delivery_status.get('message')
133
134 if 'reason' in event_data:
135 reject_reason = self.reject_reasons.get(event_data['reason'], RejectReason.OTHER)
136 else:
137 reject_reason = None
138
139 if event_type == EventType.REJECTED:
140 # This event has a somewhat different structure than the others...
141 description = description or event_data.get("reject", {}).get("reason")
142 reject_reason = reject_reason or RejectReason.OTHER
143 if not recipient:
144 try:
145 to_email = parse_single_address(
146 event_data["message"]["headers"]["to"])
147 except (AnymailInvalidAddress, KeyError):
148 pass
149 else:
150 recipient = to_email.addr_spec
151
152 return AnymailTrackingEvent(
153 event_type=event_type,
154 timestamp=timestamp,
155 message_id=message_id,
156 event_id=event_id,
157 recipient=recipient,
158 reject_reason=reject_reason,
159 description=description,
160 mta_response=mta_response,
161 tags=tags,
162 metadata=metadata,
163 click_url=event_data.get('url'),
164 user_agent=event_data.get('client-info', {}).get('user-agent'),
165 esp_event=esp_event,
166 )
167
168 # Legacy event handling
169 # (Prior to 2018-06-29, these were the only Mailgun events.)
170
171 legacy_event_types = {
51172 # Map Mailgun event: Anymail normalized type
52173 'delivered': EventType.DELIVERED,
53174 'dropped': EventType.REJECTED,
59180 # Mailgun does not send events corresponding to QUEUED or DEFERRED
60181 }
61182
62 reject_reasons = {
183 legacy_reject_reasons = {
63184 # Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
64185 # By default, we will treat anything 400-599 as REJECT_BOUNCED
65186 # so only exceptions are listed here.
70191 607: RejectReason.SPAM, # previous spam complaint
71192 }
72193
73 def parse_events(self, request):
74 return [self.esp_to_anymail_event(request.POST)]
75
76 def esp_to_anymail_event(self, esp_event):
194 def mailgun_legacy_to_anymail_event(self, esp_event):
77195 # esp_event is a Django QueryDict (from request.POST),
78196 # which has multi-valued fields, but is *not* case-insensitive.
79197 # Because of the way Mailgun merges user-variables into the event,
81199 # to avoid potential conflicting user-data.
82200 esp_event.getfirst = querydict_getfirst.__get__(esp_event)
83201
84 event_type = self.event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
202 if 'event' not in esp_event and 'sender' in esp_event:
203 # Inbound events don't (currently) have an event field
204 raise AnymailConfigurationError(
205 "You seem to have set Mailgun's *inbound* route "
206 "to Anymail's Mailgun *tracking* webhook URL.")
207
208 event_type = self.legacy_event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
85209 timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp
86210 # Message-Id is not documented for every event, but seems to always be included.
87211 # (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
106230 else:
107231 reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER
108232 else:
109 reject_reason = self.reject_reasons.get(
233 reject_reason = self.legacy_reject_reasons.get(
110234 mta_status,
111235 RejectReason.BOUNCED if 400 <= mta_status < 600
112236 else RejectReason.OTHER)
113237
114 metadata = self._extract_metadata(esp_event)
238 metadata = self._extract_legacy_metadata(esp_event)
115239
116240 # tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
117241 tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', [])
132256 esp_event=esp_event,
133257 )
134258
135 def _extract_metadata(self, esp_event):
259 def _extract_legacy_metadata(self, esp_event):
136260 # Mailgun merges user-variables into the POST fields. If you know which user variable
137261 # you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
138262 # But if you want to extract all user-variables (like we do), it's more complicated...
148272 # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
149273 metadata = combine(*[json.loads(value) for value in variables])
150274
151 elif event_type in self._known_event_fields:
275 elif event_type in self._known_legacy_event_fields:
152276 # For other events, we must extract from the POST fields, ignoring known Mailgun
153277 # event parameters, and treating all other values as user-variables.
154 known_fields = self._known_event_fields[event_type]
278 known_fields = self._known_legacy_event_fields[event_type]
155279 for field, values in esp_event.lists():
156280 if field not in known_fields:
157281 # Unknown fields are assumed to be user-variables. (There should really only be
176300
177301 return metadata
178302
179 _common_event_fields = {
303 _common_legacy_event_fields = {
180304 # These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
181305 'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type',
182306 'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list',
184308 # Undocumented, but observed in actual events:
185309 'body-plain', 'h', 'message-id',
186310 }
187 _known_event_fields = {
311 _known_legacy_event_fields = {
188312 # For all Mailgun event types that *don't* include message-headers,
189313 # map Mailgun (not normalized) event type to set of expected event fields.
190314 # Used for metadata extraction.
191 'clicked': _common_event_fields | {'url'},
192 'opened': _common_event_fields,
193 'unsubscribed': _common_event_fields,
315 'clicked': _common_legacy_event_fields | {'url'},
316 'opened': _common_legacy_event_fields,
317 'unsubscribed': _common_legacy_event_fields,
194318 }
195319
196320
200324 signal = inbound
201325
202326 def parse_events(self, request):
327 if request.content_type == "application/json":
328 esp_event = json.loads(request.body.decode('utf-8'))
329 event_type = esp_event.get('event-data', {}).get('event', '')
330 raise AnymailConfigurationError(
331 "You seem to have set Mailgun's *%s tracking* webhook "
332 "to Anymail's Mailgun *inbound* webhook URL. "
333 "(Or Mailgun has changed inbound events to use json.)"
334 % event_type)
203335 return [self.esp_to_anymail_event(request)]
204336
205337 def esp_to_anymail_event(self, request):
206338 # Inbound uses the entire Django request as esp_event, because we need POST and FILES.
207339 # Note that request.POST is case-sensitive (unlike email.message.Message headers).
208340 esp_event = request
341
342 if request.POST.get('event', 'inbound') != 'inbound':
343 # (Legacy) tracking event
344 raise AnymailConfigurationError(
345 "You seem to have set Mailgun's *%s tracking* webhook "
346 "to Anymail's Mailgun *inbound* webhook URL." % request.POST['event'])
347
209348 if 'body-mime' in request.POST:
210349 # Raw-MIME
211350 message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime'])
101101 except KeyError:
102102 event_id = None
103103
104 metadata = esp_event.get('Metadata', {})
104105 try:
105106 tags = [esp_event['Tag']]
106107 except KeyError:
112113 event_id=event_id,
113114 event_type=event_type,
114115 message_id=esp_event.get('MessageID', None),
116 metadata=metadata,
115117 mta_response=esp_event.get('Details', None),
116118 recipient=recipient,
117119 reject_reason=reject_reason,
0 Metadata-Version: 1.2
0 Metadata-Version: 2.1
11 Name: django-anymail
2 Version: 3.0
2 Version: 5.0
33 Summary: Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs
44 Home-page: https://github.com/anymail/django-anymail
55 Author: Mike Edmunds and Anymail contributors
66 Author-email: medmunds@gmail.com
77 License: BSD License
8 Project-URL: Documentation, https://anymail.readthedocs.io/en/v3.0/
8 Project-URL: Documentation, https://anymail.readthedocs.io/en/v5.0/
99 Project-URL: Source, https://github.com/anymail/django-anymail
10 Project-URL: Changelog, https://github.com/anymail/django-anymail/releases
10 Project-URL: Changelog, https://anymail.readthedocs.io/en/v5.0/changelog/
1111 Project-URL: Tracker, https://github.com/anymail/django-anymail/issues
12 Description-Content-Type: UNKNOWN
1312 Description: Anymail: Django email integration for transactional ESPs
1413 ========================================================
1514
4241 built-in `django.core.mail` package. It includes:
4342
4443 * Support for HTML, attachments, extra headers, and other features of
45 `Django's built-in email <https://docs.djangoproject.com/en/v3.0/topics/email/>`_
44 `Django's built-in email <https://docs.djangoproject.com/en/v5.0/topics/email/>`_
4645 * Extensions that make it easy to use extra ESP functionality, like tags, metadata,
4746 and tracking, with code that's portable between ESPs
4847 * Simplified inline images for HTML email
5352 with simplified, portable access to attachments and other inbound content
5453
5554 Anymail is released under the BSD license. It is extensively tested against
56 Django 1.8--2.1 (including Python 2.7, Python 3 and PyPy).
55 Django 1.11--2.1 (including Python 2.7, Python 3 and PyPy).
5756 Anymail releases follow `semantic versioning <http://semver.org/>`_.
5857
5958 .. END shared-intro
6059
61 .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v3.0
60 .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v5.0
6261 :target: https://travis-ci.org/anymail/django-anymail
6362 :alt: build status on Travis-CI
6463
65 .. image:: https://readthedocs.org/projects/anymail/badge/?version=v3.0
66 :target: https://anymail.readthedocs.io/en/v3.0/
64 .. image:: https://readthedocs.org/projects/anymail/badge/?version=v5.0
65 :target: https://anymail.readthedocs.io/en/v5.0/
6766 :alt: documentation on ReadTheDocs
6867
6968 **Resources**
7069
71 * Full documentation: https://anymail.readthedocs.io/en/v3.0/
72 * Package on PyPI: https://pypi.python.org/pypi/django-anymail
70 * Full documentation: https://anymail.readthedocs.io/en/v5.0/
71 * Package on PyPI: https://pypi.org/project/django-anymail/
7372 * Project on Github: https://github.com/anymail/django-anymail
74 * Changelog: https://github.com/anymail/django-anymail/releases
73 * Changelog: https://anymail.readthedocs.io/en/v5.0/changelog/
7574
7675
7776 Anymail 1-2-3
114113 DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings
115114
116115
117 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v3.0/topics/email/>`_
116 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v5.0/topics/email/>`_
118117 will send through your chosen ESP:
119118
120119 .. code-block:: python
158157 .. END quickstart
159158
160159
161 See the `full documentation <https://anymail.readthedocs.io/en/v3.0/>`_
160 See the `full documentation <https://anymail.readthedocs.io/en/v5.0/>`_
162161 for more features and options, including receiving messages and tracking
163162 sent message status.
164163
180179 Classifier: Topic :: Software Development :: Libraries :: Python Modules
181180 Classifier: Intended Audience :: Developers
182181 Classifier: Framework :: Django
183 Classifier: Framework :: Django :: 1.8
184 Classifier: Framework :: Django :: 1.9
185 Classifier: Framework :: Django :: 1.10
186182 Classifier: Framework :: Django :: 1.11
187183 Classifier: Framework :: Django :: 2.0
184 Classifier: Framework :: Django :: 2.1
188185 Classifier: Environment :: Web Environment
186 Provides-Extra: mandrill
187 Provides-Extra: sendinblue
188 Provides-Extra: sparkpost
189 Provides-Extra: mailgun
190 Provides-Extra: sendgrid
191 Provides-Extra: mailjet
192 Provides-Extra: amazon_ses
193 Provides-Extra: postmark
2424 anymail/backends/mandrill.py
2525 anymail/backends/postmark.py
2626 anymail/backends/sendgrid.py
27 anymail/backends/sendgrid_v2.py
2827 anymail/backends/sendinblue.py
2928 anymail/backends/sparkpost.py
3029 anymail/backends/test.py
0 django>=1.8
0 django>=1.11
11 requests>=2.4.3
22 six
33
4242 license="BSD License",
4343 packages=["anymail"],
4444 zip_safe=False,
45 install_requires=["django>=1.8", "requests>=2.4.3", "six"],
45 install_requires=["django>=1.11", "requests>=2.4.3", "six"],
4646 extras_require={
4747 # This can be used if particular backends have unique dependencies.
4848 # For simplicity, requests is included in the base requirements.
7575 "Topic :: Software Development :: Libraries :: Python Modules",
7676 "Intended Audience :: Developers",
7777 "Framework :: Django",
78 "Framework :: Django :: 1.8",
79 "Framework :: Django :: 1.9",
80 "Framework :: Django :: 1.10",
8178 "Framework :: Django :: 1.11",
8279 "Framework :: Django :: 2.0",
83 # "Framework :: Django :: 2.1",
80 "Framework :: Django :: 2.1",
8481 "Environment :: Web Environment",
8582 ],
8683 long_description=long_description,
8784 project_urls=OrderedDict([
8885 ("Documentation", "https://anymail.readthedocs.io/en/%s/" % release_tag),
8986 ("Source", "https://github.com/anymail/django-anymail"),
90 ("Changelog", "https://github.com/anymail/django-anymail/releases"),
87 ("Changelog", "https://anymail.readthedocs.io/en/%s/changelog/" % release_tag),
9188 ("Tracker", "https://github.com/anymail/django-anymail/issues"),
9289 ]),
9390 )