Imported Upstream version 5.0
Scott Kitterman
5 years ago
0 | Metadata-Version: 1.2 | |
0 | Metadata-Version: 2.1 | |
1 | 1 | Name: django-anymail |
2 | Version: 3.0 | |
2 | Version: 5.0 | |
3 | 3 | Summary: Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs |
4 | 4 | Home-page: https://github.com/anymail/django-anymail |
5 | 5 | Author: Mike Edmunds and Anymail contributors |
6 | 6 | Author-email: medmunds@gmail.com |
7 | 7 | 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/ | |
9 | 9 | 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/ | |
11 | 11 | Project-URL: Tracker, https://github.com/anymail/django-anymail/issues |
12 | Description-Content-Type: UNKNOWN | |
13 | 12 | Description: Anymail: Django email integration for transactional ESPs |
14 | 13 | ======================================================== |
15 | 14 | |
42 | 41 | built-in `django.core.mail` package. It includes: |
43 | 42 | |
44 | 43 | * 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/>`_ | |
46 | 45 | * Extensions that make it easy to use extra ESP functionality, like tags, metadata, |
47 | 46 | and tracking, with code that's portable between ESPs |
48 | 47 | * Simplified inline images for HTML email |
53 | 52 | with simplified, portable access to attachments and other inbound content |
54 | 53 | |
55 | 54 | 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). | |
57 | 56 | Anymail releases follow `semantic versioning <http://semver.org/>`_. |
58 | 57 | |
59 | 58 | .. END shared-intro |
60 | 59 | |
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 | |
62 | 61 | :target: https://travis-ci.org/anymail/django-anymail |
63 | 62 | :alt: build status on Travis-CI |
64 | 63 | |
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/ | |
67 | 66 | :alt: documentation on ReadTheDocs |
68 | 67 | |
69 | 68 | **Resources** |
70 | 69 | |
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/ | |
73 | 72 | * 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/ | |
75 | 74 | |
76 | 75 | |
77 | 76 | Anymail 1-2-3 |
114 | 113 | DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings |
115 | 114 | |
116 | 115 | |
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/>`_ | |
118 | 117 | will send through your chosen ESP: |
119 | 118 | |
120 | 119 | .. code-block:: python |
158 | 157 | .. END quickstart |
159 | 158 | |
160 | 159 | |
161 | See the `full documentation <https://anymail.readthedocs.io/en/v3.0/>`_ | |
160 | See the `full documentation <https://anymail.readthedocs.io/en/v5.0/>`_ | |
162 | 161 | for more features and options, including receiving messages and tracking |
163 | 162 | sent message status. |
164 | 163 | |
180 | 179 | Classifier: Topic :: Software Development :: Libraries :: Python Modules |
181 | 180 | Classifier: Intended Audience :: Developers |
182 | 181 | Classifier: Framework :: Django |
183 | Classifier: Framework :: Django :: 1.8 | |
184 | Classifier: Framework :: Django :: 1.9 | |
185 | Classifier: Framework :: Django :: 1.10 | |
186 | 182 | Classifier: Framework :: Django :: 1.11 |
187 | 183 | Classifier: Framework :: Django :: 2.0 |
184 | Classifier: Framework :: Django :: 2.1 | |
188 | 185 | 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 |
40 | 40 | with simplified, portable access to attachments and other inbound content |
41 | 41 | |
42 | 42 | 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). | |
44 | 44 | Anymail releases follow `semantic versioning <http://semver.org/>`_. |
45 | 45 | |
46 | 46 | .. END shared-intro |
56 | 56 | **Resources** |
57 | 57 | |
58 | 58 | * 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/ | |
60 | 60 | * 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/ | |
62 | 62 | |
63 | 63 | |
64 | 64 | Anymail 1-2-3 |
0 | VERSION = (3, 0) | |
0 | VERSION = (5, 0) | |
1 | 1 | __version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN |
2 | 2 | __minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version" |
0 | from email.charset import Charset, QP | |
0 | 1 | from email.header import Header |
1 | 2 | from email.mime.base import MIMEBase |
3 | from email.mime.text import MIMEText | |
2 | 4 | |
3 | 5 | from django.core.mail import BadHeaderError |
4 | 6 | |
128 | 130 | if HeaderBugWorkaround and "Subject" in self.mime_message: |
129 | 131 | # (message.message() will have already checked subject for BadHeaderError) |
130 | 132 | 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) | |
131 | 150 | |
132 | 151 | def call_send_api(self, ses_client): |
133 | 152 | self.params["RawMessage"] = { |
28 | 28 | kwargs=kwargs, default=False) |
29 | 29 | self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status', |
30 | 30 | kwargs=kwargs, default=False) |
31 | self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output | |
32 | kwargs=kwargs, default=False) | |
31 | 33 | |
32 | 34 | # Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings |
33 | 35 | send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs |
0 | from __future__ import print_function | |
1 | ||
0 | 2 | import requests |
3 | import six | |
1 | 4 | from six.moves.urllib.parse import urljoin |
2 | 5 | |
3 | 6 | from anymail.utils import get_anymail_setting |
31 | 34 | self.session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format( |
32 | 35 | esp=self.esp_name.lower(), version=__version__, |
33 | 36 | orig=self.session.headers.get("User-Agent", "")) |
37 | if self.debug_api_requests: | |
38 | self.session.hooks['response'].append(self._dump_api_request) | |
34 | 39 | return True |
35 | 40 | |
36 | 41 | def close(self): |
99 | 104 | email_message=message, payload=payload, response=response, |
100 | 105 | backend=self) |
101 | 106 | |
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 | ||
102 | 134 | |
103 | 135 | class RequestsPayload(BasePayload): |
104 | 136 | """Abstract Payload for AnymailRequestsBackend""" |
0 | 0 | from datetime import datetime |
1 | from email.utils import encode_rfc2231 | |
2 | ||
3 | from requests import Request | |
1 | 4 | |
2 | 5 | from ..exceptions import AnymailRequestsAPIError, AnymailError |
3 | 6 | from ..message import AnymailRecipientStatus |
77 | 80 | backend=self.backend, email_message=self.message, payload=self) |
78 | 81 | return "%s/messages" % self.sender_domain |
79 | 82 | |
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 | ||
80 | 112 | def serialize_data(self): |
81 | 113 | self.populate_recipient_variables() |
82 | 114 | return self.data |
112 | 144 | def init_payload(self): |
113 | 145 | self.data = {} # {field: [multiple, values]} |
114 | 146 | self.files = [] # [(field, multiple), (field, values)] |
147 | self.headers = {} | |
115 | 148 | |
116 | 149 | def set_from_email_list(self, emails): |
117 | 150 | # Mailgun supports multiple From email addresses |
154 | 187 | if attachment.inline: |
155 | 188 | field = "inline" |
156 | 189 | name = attachment.cid |
190 | if not name: | |
191 | self.unsupported_feature("inline attachments without Content-ID") | |
157 | 192 | else: |
158 | 193 | field = "attachment" |
159 | 194 | name = attachment.name |
195 | if not name: | |
196 | self.unsupported_feature("attachments without filenames") | |
160 | 197 | self.files.append( |
161 | 198 | (field, (name, attachment.content, attachment.mimetype)) |
162 | 199 | ) |
203 | 240 | # Allow override of sender_domain via esp_extra |
204 | 241 | # (but pop it out of params to send to Mailgun) |
205 | 242 | 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 |
1 | 1 | |
2 | 2 | from ..exceptions import AnymailRequestsAPIError |
3 | 3 | from ..message import AnymailRecipientStatus |
4 | from ..utils import get_anymail_setting | |
4 | from ..utils import get_anymail_setting, parse_address_list | |
5 | 5 | |
6 | 6 | from .base_requests import AnymailRequestsBackend, RequestsPayload |
7 | 7 | |
32 | 32 | super(EmailBackend, self).raise_for_status(response, payload, message) |
33 | 33 | |
34 | 34 | 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 | ||
35 | 39 | 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 | |
51 | 106 | raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, |
52 | 107 | 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 | |
81 | 117 | """ |
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) | |
87 | 119 | if match: |
88 | 120 | emails = match.group(1) # "one@xample.com, two@example.com" |
89 | 121 | return [email.strip().lower() for email in emails.split(',')] |
100 | 132 | # 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra) |
101 | 133 | } |
102 | 134 | 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 | |
104 | 137 | super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs) |
105 | 138 | |
106 | 139 | 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/" | |
110 | 147 | else: |
111 | return "email" | |
148 | if batch_send: | |
149 | return "email/batch" | |
150 | else: | |
151 | return "email" | |
112 | 152 | |
113 | 153 | def get_request_params(self, api_url): |
114 | 154 | params = super(PostmarkPayload, self).get_request_params(api_url) |
116 | 156 | return params |
117 | 157 | |
118 | 158 | 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 | |
120 | 179 | |
121 | 180 | # |
122 | 181 | # Payload construction |
135 | 194 | if emails: |
136 | 195 | field = recipient_type.capitalize() |
137 | 196 | 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 | |
139 | 199 | |
140 | 200 | def set_subject(self, subject): |
141 | 201 | self.data["Subject"] = subject |
177 | 237 | self.make_attachment(attachment) for attachment in attachments |
178 | 238 | ] |
179 | 239 | |
180 | # Postmark doesn't support metadata | |
181 | # def set_metadata(self, metadata): | |
240 | def set_metadata(self, metadata): | |
241 | self.data["Metadata"] = metadata | |
182 | 242 | |
183 | 243 | # Postmark doesn't support delayed sending |
184 | 244 | # def set_send_at(self, send_at): |
196 | 256 | self.data["TrackOpens"] = track_opens |
197 | 257 | |
198 | 258 | 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 | |
202 | 273 | |
203 | 274 | def set_merge_global_data(self, merge_global_data): |
204 | 275 | self.data["TemplateModel"] = merge_global_data |
0 | 0 | import uuid |
1 | import warnings | |
1 | 2 | from email.utils import quote as rfc822_quote |
2 | import warnings | |
3 | 3 | |
4 | 4 | from requests.structures import CaseInsensitiveDict |
5 | 5 | |
6 | 6 | from .base_requests import AnymailRequestsBackend, RequestsPayload |
7 | 7 | from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning |
8 | 8 | 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 | |
10 | 10 | |
11 | 11 | |
12 | 12 | class EmailBackend(AnymailRequestsBackend): |
25 | 25 | password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) |
26 | 26 | if username or password: |
27 | 27 | 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.") | |
30 | 29 | |
31 | 30 | self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) |
32 | 31 | |
72 | 71 | self.all_recipients = [] # used for backend.parse_recipient_status |
73 | 72 | self.generate_message_id = backend.generate_message_id |
74 | 73 | self.workaround_name_quote_bug = backend.workaround_name_quote_bug |
74 | self.use_dynamic_template = False # how to represent merge_data | |
75 | 75 | self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers |
76 | 76 | self.merge_field_format = backend.merge_field_format |
77 | 77 | self.merge_data = None # late-bound per-recipient data |
113 | 113 | self.data.setdefault("custom_args", {})["anymail_id"] = self.message_id |
114 | 114 | |
115 | 115 | 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): | |
116 | 151 | """Set personalizations[...]['substitutions'] and data['sections']""" |
117 | 152 | merge_field_format = self.merge_field_format or '{}' |
118 | 153 | |
135 | 170 | pass # no merge_data for this recipient |
136 | 171 | self.data["personalizations"].append(personalization) |
137 | 172 | |
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): | |
139 | 174 | warnings.warn( |
140 | 175 | "Your SendGrid merge fields don't seem to have delimiters, " |
141 | 176 | "which can cause unexpected results with Anymail's merge_data. " |
291 | 326 | |
292 | 327 | def set_template_id(self, template_id): |
293 | 328 | self.data["template_id"] = template_id |
329 | try: | |
330 | self.use_dynamic_template = template_id.startswith("d-") | |
331 | except AttributeError: | |
332 | pass | |
294 | 333 | |
295 | 334 | 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. | |
298 | 338 | self.merge_data = merge_data |
299 | 339 | |
300 | 340 | 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. | |
302 | 344 | self.merge_global_data = merge_global_data |
303 | 345 | |
304 | 346 | def set_esp_extra(self, extra): |
305 | 347 | 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")) | |
306 | 353 | if "x-smtpapi" in extra: |
307 | 354 | raise AnymailConfigurationError( |
308 | 355 | "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." | |
311 | 357 | ) |
312 | 358 | update_deep(self.data, extra) |
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) |
2 | 2 | from .base import AnymailBaseBackend, BasePayload |
3 | 3 | from ..exceptions import AnymailAPIError |
4 | 4 | from ..message import AnymailRecipientStatus |
5 | from ..utils import get_anymail_setting | |
6 | 5 | |
7 | 6 | |
8 | 7 | class EmailBackend(AnymailBaseBackend): |
136 | 135 | def set_esp_extra(self, extra): |
137 | 136 | # Merge extra into params |
138 | 137 | 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) |
58 | 58 | |
59 | 59 | def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None): |
60 | 60 | """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 | |
61 | 65 | content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>) |
62 | 66 | image = MIMEImage(content, subtype) |
63 | 67 | image.add_header('Content-Disposition', 'inline', filename=filename) |
0 | 0 | import base64 |
1 | 1 | import mimetypes |
2 | 2 | from base64 import b64encode |
3 | from collections import Mapping, MutableMapping | |
4 | 3 | from datetime import datetime |
5 | 4 | from email.mime.base import MIMEBase |
6 | 5 | from email.utils import formatdate, getaddresses, unquote |
13 | 12 | from django.utils.functional import Promise |
14 | 13 | from django.utils.timezone import utc, get_fixed_timezone |
15 | 14 | 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 | |
16 | 20 | |
17 | 21 | from .exceptions import AnymailConfigurationError, AnymailInvalidAddress |
18 | 22 | |
286 | 290 | self.content = attachment.as_string().encode(self.encoding) |
287 | 291 | self.mimetype = attachment.get_content_type() |
288 | 292 | |
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): | |
290 | 295 | self.inline = True |
291 | 296 | self.content_id = attachment["Content-ID"] # probably including the <...> |
292 | 297 | if self.content_id is not None: |
6 | 6 | from django.utils.timezone import utc |
7 | 7 | |
8 | 8 | from .base import AnymailBaseWebhookView |
9 | from ..exceptions import AnymailWebhookValidationFailure | |
9 | from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress | |
10 | 10 | from ..inbound import AnymailInboundMessage |
11 | 11 | 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 | |
13 | 13 | |
14 | 14 | |
15 | 15 | class MailgunBaseWebhookView(AnymailBaseWebhookView): |
28 | 28 | |
29 | 29 | def validate_request(self, request): |
30 | 30 | 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 | ||
39 | 54 | expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'), |
40 | 55 | digestmod=hashlib.sha256).hexdigest() |
41 | 56 | if not constant_time_compare(signature, expected_signature): |
47 | 62 | |
48 | 63 | signal = tracking |
49 | 64 | |
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 | ||
50 | 72 | 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 = { | |
51 | 172 | # Map Mailgun event: Anymail normalized type |
52 | 173 | 'delivered': EventType.DELIVERED, |
53 | 174 | 'dropped': EventType.REJECTED, |
59 | 180 | # Mailgun does not send events corresponding to QUEUED or DEFERRED |
60 | 181 | } |
61 | 182 | |
62 | reject_reasons = { | |
183 | legacy_reject_reasons = { | |
63 | 184 | # Map Mailgun (SMTP) error codes to Anymail normalized reject_reason. |
64 | 185 | # By default, we will treat anything 400-599 as REJECT_BOUNCED |
65 | 186 | # so only exceptions are listed here. |
70 | 191 | 607: RejectReason.SPAM, # previous spam complaint |
71 | 192 | } |
72 | 193 | |
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): | |
77 | 195 | # esp_event is a Django QueryDict (from request.POST), |
78 | 196 | # which has multi-valued fields, but is *not* case-insensitive. |
79 | 197 | # Because of the way Mailgun merges user-variables into the event, |
81 | 199 | # to avoid potential conflicting user-data. |
82 | 200 | esp_event.getfirst = querydict_getfirst.__get__(esp_event) |
83 | 201 | |
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) | |
85 | 209 | timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp |
86 | 210 | # Message-Id is not documented for every event, but seems to always be included. |
87 | 211 | # (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.) |
106 | 230 | else: |
107 | 231 | reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER |
108 | 232 | else: |
109 | reject_reason = self.reject_reasons.get( | |
233 | reject_reason = self.legacy_reject_reasons.get( | |
110 | 234 | mta_status, |
111 | 235 | RejectReason.BOUNCED if 400 <= mta_status < 600 |
112 | 236 | else RejectReason.OTHER) |
113 | 237 | |
114 | metadata = self._extract_metadata(esp_event) | |
238 | metadata = self._extract_legacy_metadata(esp_event) | |
115 | 239 | |
116 | 240 | # tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag |
117 | 241 | tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', []) |
132 | 256 | esp_event=esp_event, |
133 | 257 | ) |
134 | 258 | |
135 | def _extract_metadata(self, esp_event): | |
259 | def _extract_legacy_metadata(self, esp_event): | |
136 | 260 | # Mailgun merges user-variables into the POST fields. If you know which user variable |
137 | 261 | # you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine. |
138 | 262 | # But if you want to extract all user-variables (like we do), it's more complicated... |
148 | 272 | # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict: |
149 | 273 | metadata = combine(*[json.loads(value) for value in variables]) |
150 | 274 | |
151 | elif event_type in self._known_event_fields: | |
275 | elif event_type in self._known_legacy_event_fields: | |
152 | 276 | # For other events, we must extract from the POST fields, ignoring known Mailgun |
153 | 277 | # 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] | |
155 | 279 | for field, values in esp_event.lists(): |
156 | 280 | if field not in known_fields: |
157 | 281 | # Unknown fields are assumed to be user-variables. (There should really only be |
176 | 300 | |
177 | 301 | return metadata |
178 | 302 | |
179 | _common_event_fields = { | |
303 | _common_legacy_event_fields = { | |
180 | 304 | # These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events: |
181 | 305 | 'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type', |
182 | 306 | 'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list', |
184 | 308 | # Undocumented, but observed in actual events: |
185 | 309 | 'body-plain', 'h', 'message-id', |
186 | 310 | } |
187 | _known_event_fields = { | |
311 | _known_legacy_event_fields = { | |
188 | 312 | # For all Mailgun event types that *don't* include message-headers, |
189 | 313 | # map Mailgun (not normalized) event type to set of expected event fields. |
190 | 314 | # 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, | |
194 | 318 | } |
195 | 319 | |
196 | 320 | |
200 | 324 | signal = inbound |
201 | 325 | |
202 | 326 | 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) | |
203 | 335 | return [self.esp_to_anymail_event(request)] |
204 | 336 | |
205 | 337 | def esp_to_anymail_event(self, request): |
206 | 338 | # Inbound uses the entire Django request as esp_event, because we need POST and FILES. |
207 | 339 | # Note that request.POST is case-sensitive (unlike email.message.Message headers). |
208 | 340 | 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 | ||
209 | 348 | if 'body-mime' in request.POST: |
210 | 349 | # Raw-MIME |
211 | 350 | message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime']) |
101 | 101 | except KeyError: |
102 | 102 | event_id = None |
103 | 103 | |
104 | metadata = esp_event.get('Metadata', {}) | |
104 | 105 | try: |
105 | 106 | tags = [esp_event['Tag']] |
106 | 107 | except KeyError: |
112 | 113 | event_id=event_id, |
113 | 114 | event_type=event_type, |
114 | 115 | message_id=esp_event.get('MessageID', None), |
116 | metadata=metadata, | |
115 | 117 | mta_response=esp_event.get('Details', None), |
116 | 118 | recipient=recipient, |
117 | 119 | reject_reason=reject_reason, |
0 | Metadata-Version: 1.2 | |
0 | Metadata-Version: 2.1 | |
1 | 1 | Name: django-anymail |
2 | Version: 3.0 | |
2 | Version: 5.0 | |
3 | 3 | Summary: Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs |
4 | 4 | Home-page: https://github.com/anymail/django-anymail |
5 | 5 | Author: Mike Edmunds and Anymail contributors |
6 | 6 | Author-email: medmunds@gmail.com |
7 | 7 | 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/ | |
9 | 9 | 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/ | |
11 | 11 | Project-URL: Tracker, https://github.com/anymail/django-anymail/issues |
12 | Description-Content-Type: UNKNOWN | |
13 | 12 | Description: Anymail: Django email integration for transactional ESPs |
14 | 13 | ======================================================== |
15 | 14 | |
42 | 41 | built-in `django.core.mail` package. It includes: |
43 | 42 | |
44 | 43 | * 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/>`_ | |
46 | 45 | * Extensions that make it easy to use extra ESP functionality, like tags, metadata, |
47 | 46 | and tracking, with code that's portable between ESPs |
48 | 47 | * Simplified inline images for HTML email |
53 | 52 | with simplified, portable access to attachments and other inbound content |
54 | 53 | |
55 | 54 | 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). | |
57 | 56 | Anymail releases follow `semantic versioning <http://semver.org/>`_. |
58 | 57 | |
59 | 58 | .. END shared-intro |
60 | 59 | |
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 | |
62 | 61 | :target: https://travis-ci.org/anymail/django-anymail |
63 | 62 | :alt: build status on Travis-CI |
64 | 63 | |
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/ | |
67 | 66 | :alt: documentation on ReadTheDocs |
68 | 67 | |
69 | 68 | **Resources** |
70 | 69 | |
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/ | |
73 | 72 | * 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/ | |
75 | 74 | |
76 | 75 | |
77 | 76 | Anymail 1-2-3 |
114 | 113 | DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings |
115 | 114 | |
116 | 115 | |
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/>`_ | |
118 | 117 | will send through your chosen ESP: |
119 | 118 | |
120 | 119 | .. code-block:: python |
158 | 157 | .. END quickstart |
159 | 158 | |
160 | 159 | |
161 | See the `full documentation <https://anymail.readthedocs.io/en/v3.0/>`_ | |
160 | See the `full documentation <https://anymail.readthedocs.io/en/v5.0/>`_ | |
162 | 161 | for more features and options, including receiving messages and tracking |
163 | 162 | sent message status. |
164 | 163 | |
180 | 179 | Classifier: Topic :: Software Development :: Libraries :: Python Modules |
181 | 180 | Classifier: Intended Audience :: Developers |
182 | 181 | Classifier: Framework :: Django |
183 | Classifier: Framework :: Django :: 1.8 | |
184 | Classifier: Framework :: Django :: 1.9 | |
185 | Classifier: Framework :: Django :: 1.10 | |
186 | 182 | Classifier: Framework :: Django :: 1.11 |
187 | 183 | Classifier: Framework :: Django :: 2.0 |
184 | Classifier: Framework :: Django :: 2.1 | |
188 | 185 | 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 |
24 | 24 | anymail/backends/mandrill.py |
25 | 25 | anymail/backends/postmark.py |
26 | 26 | anymail/backends/sendgrid.py |
27 | anymail/backends/sendgrid_v2.py | |
28 | 27 | anymail/backends/sendinblue.py |
29 | 28 | anymail/backends/sparkpost.py |
30 | 29 | anymail/backends/test.py |
42 | 42 | license="BSD License", |
43 | 43 | packages=["anymail"], |
44 | 44 | 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"], | |
46 | 46 | extras_require={ |
47 | 47 | # This can be used if particular backends have unique dependencies. |
48 | 48 | # For simplicity, requests is included in the base requirements. |
75 | 75 | "Topic :: Software Development :: Libraries :: Python Modules", |
76 | 76 | "Intended Audience :: Developers", |
77 | 77 | "Framework :: Django", |
78 | "Framework :: Django :: 1.8", | |
79 | "Framework :: Django :: 1.9", | |
80 | "Framework :: Django :: 1.10", | |
81 | 78 | "Framework :: Django :: 1.11", |
82 | 79 | "Framework :: Django :: 2.0", |
83 | # "Framework :: Django :: 2.1", | |
80 | "Framework :: Django :: 2.1", | |
84 | 81 | "Environment :: Web Environment", |
85 | 82 | ], |
86 | 83 | long_description=long_description, |
87 | 84 | project_urls=OrderedDict([ |
88 | 85 | ("Documentation", "https://anymail.readthedocs.io/en/%s/" % release_tag), |
89 | 86 | ("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), | |
91 | 88 | ("Tracker", "https://github.com/anymail/django-anymail/issues"), |
92 | 89 | ]), |
93 | 90 | ) |