From e568e50d0cf2045c23c0128a1dd69a5c2602587d Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 19 Jan 2017 14:29:15 -0800 Subject: [PATCH] SendGrid: update to new v3 send API (#50) SendGrid: update to v3 send API **SendGrid:** **[possibly-breaking]** Update SendGrid backend to newer Web API v3. This should be a transparent change for most projects. Exceptions: if you use SendGrid username/password auth, esp_extra with "x-smtpapi", or multiple Reply-To addresses, please review the [porting notes](http://anymail.readthedocs.io/en/latest/esps/sendgrid/#sendgrid-v3-upgrade). Closes #28 --- anymail/backends/sendgrid.py | 353 ++++++++------- anymail/backends/sendgrid_v2.py | 315 +++++++++++++ anymail/utils.py | 15 + docs/esps/sendgrid.rst | 308 ++++++++++--- tests/mock_requests_backend.py | 3 +- tests/test_general_backend.py | 6 +- tests/test_sendgrid_backend.py | 568 ++++++++++++----------- tests/test_sendgrid_integration.py | 89 ++-- tests/test_sendgrid_v2_backend.py | 625 ++++++++++++++++++++++++++ tests/test_sendgrid_v2_integration.py | 146 ++++++ tests/test_utils.py | 28 +- 11 files changed, 1891 insertions(+), 565 deletions(-) create mode 100644 anymail/backends/sendgrid_v2.py create mode 100644 tests/test_sendgrid_v2_backend.py create mode 100644 tests/test_sendgrid_v2_integration.py diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 8434735..423685d 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -1,44 +1,49 @@ +from email.utils import quote as rfc822_quote import warnings from django.core.mail import make_msgid from requests.structures import CaseInsensitiveDict +from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning from ..message import AnymailRecipientStatus -from ..utils import get_anymail_setting, timestamp - -from .base_requests import AnymailRequestsBackend, RequestsPayload +from ..utils import get_anymail_setting, timestamp, update_deep class SendGridBackend(AnymailRequestsBackend): """ - SendGrid API Email Backend + SendGrid v3 API Email Backend """ def __init__(self, **kwargs): """Init options from Django settings""" - # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD esp_name = self.esp_name - self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, - default=None, allow_bare=True) - self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, - default=None, allow_bare=True) - self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, - default=None, allow_bare=True) - if self.api_key is None and (self.username is None or self.password is None): + + # Warn if v2-only username or password settings found + username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) + password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) + if username or password: raise AnymailConfigurationError( - "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and " - "SENDGRID_PASSWORD in your Django ANYMAIL settings." - ) + "SendGrid v3 API doesn't support username/password auth; Please change to API key.\n" + "(For legacy v2 API, use anymail.backends.sendgrid_v2.EmailBackend.)") + + self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name, kwargs=kwargs, default=True) self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name, kwargs=kwargs, default=None) - # This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending) + # Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below). + # If/when SendGrid fixes their API, recipient names will end up with extra double quotes + # until Anymail is updated to remove the workaround. In the meantime, you can disable it + # by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings. + self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name, + kwargs=kwargs, default=True) + + # This is SendGrid's newer Web API v3 api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.sendgrid.com/api/") + default="https://api.sendgrid.com/v3/") if not api_url.endswith("/"): api_url += "/" super(SendGridBackend, self).__init__(api_url, **kwargs) @@ -46,18 +51,15 @@ class SendGridBackend(AnymailRequestsBackend): def build_message_payload(self, message, defaults): return SendGridPayload(message, defaults, self) + def raise_for_status(self, response, payload, message): + if response.status_code < 200 or response.status_code >= 300: + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + def parse_recipient_status(self, response, payload, message): - parsed_response = self.deserialize_json_response(response, payload, message) - try: - sendgrid_message = parsed_response["message"] - except (KeyError, TypeError): - raise AnymailRequestsAPIError("Invalid SendGrid API response format", - email_message=message, payload=payload, response=response) - if sendgrid_message != "success": - errors = parsed_response.get("errors", []) - raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors), - email_message=message, payload=payload, response=response) - # Simulate a per-recipient status of "queued": + # If we get here, the send call was successful. + # (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.) + # SendGrid v3 doesn't provide any information in the response for a successful send, + # so simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=payload.message_id, status="queued") return {recipient.email: status for recipient in payload.all_recipients} @@ -67,78 +69,59 @@ class SendGridPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status self.generate_message_id = backend.generate_message_id + self.workaround_name_quote_bug = backend.workaround_name_quote_bug self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers - self.smtpapi = {} # SendGrid x-smtpapi field - self.to_list = [] # needed for build_merge_data self.merge_field_format = backend.merge_field_format self.merge_data = None # late-bound per-recipient data self.merge_global_data = None http_headers = kwargs.pop('headers', {}) - query_params = kwargs.pop('params', {}) - if backend.api_key is not None: - http_headers['Authorization'] = 'Bearer %s' % backend.api_key - else: - query_params['api_user'] = backend.username - query_params['api_key'] = backend.password + http_headers['Authorization'] = 'Bearer %s' % backend.api_key + http_headers['Content-Type'] = 'application/json' + http_headers['Accept'] = 'application/json' super(SendGridPayload, self).__init__(message, defaults, backend, - params=query_params, headers=http_headers, + headers=http_headers, *args, **kwargs) def get_api_endpoint(self): - return "mail.send.json" + return "mail/send" + + def init_payload(self): + self.data = { # becomes json + "personalizations": [{}], + "headers": CaseInsensitiveDict(), + } def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.generate_message_id: self.ensure_message_id() - self.build_merge_data() - if self.merge_data is not None: - # Move the 'to' recipients to smtpapi, so SG does batch send - # (else all recipients would see each other's emails). - # Regular 'to' must still be a valid email (even though "ignored")... - # we use the from_email as recommended by SG support - # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250) - self.smtpapi['to'] = [email.address for email in self.to_list] - self.data['to'] = [self.data['from']] - self.data['toname'] = [self.data.get('fromname', " ")] - # Serialize x-smtpapi to json: - if len(self.smtpapi) > 0: - # If esp_extra was also used to set x-smtpapi, need to merge it - if "x-smtpapi" in self.data: - esp_extra_smtpapi = self.data["x-smtpapi"] - for key, value in esp_extra_smtpapi.items(): - if key == "filters": - # merge filters (else it's difficult to mix esp_extra with other features) - self.smtpapi.setdefault(key, {}).update(value) - else: - # all other keys replace any current value - self.smtpapi[key] = value - self.data["x-smtpapi"] = self.serialize_json(self.smtpapi) - elif "x-smtpapi" in self.data: - self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"]) - - # Serialize extra headers to json: headers = self.data["headers"] - self.data["headers"] = self.serialize_json(dict(headers.items())) + if "Reply-To" in headers: + # Reply-To must be in its own param + reply_to = headers.pop('Reply-To') + self.set_reply_to([self.parsed_email(reply_to)]) + if len(headers) > 0: + self.data["headers"] = dict(headers) # flatten to normal dict for json serialization + else: + del self.data["headers"] # don't send empty headers - return self.data + return self.serialize_json(self.data) def ensure_message_id(self): """Ensure message has a known Message-ID for later event tracking""" - headers = self.data["headers"] - if "Message-ID" not in headers: + if "Message-ID" not in self.data["headers"]: # Only make our own if caller hasn't already provided one - headers["Message-ID"] = self.make_message_id() - self.message_id = headers["Message-ID"] + self.data["headers"]["Message-ID"] = self.make_message_id() + self.message_id = self.data["headers"]["Message-ID"] # Workaround for missing message ID (smtp-id) in SendGrid engagement events # (click and open tracking): because unique_args get merged into the raw event # record, we can supply the 'smtp-id' field for any events missing it. - self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id + self.data.setdefault("custom_args", {})["smtp-id"] = self.message_id def make_message_id(self): """Returns a Message-ID that could be used for this payload @@ -146,20 +129,33 @@ class SendGridPayload(RequestsPayload): Tries to use the from_email's domain as the Message-ID's domain """ try: - _, domain = self.data["from"].split("@") + _, domain = self.data["from"]["email"].split("@") except (AttributeError, KeyError, TypeError, ValueError): domain = None return make_msgid(domain=domain) def build_merge_data(self): - """Set smtpapi['sub'] and ['section']""" + """Set personalizations[...]['substitutions'] and data['sections']""" + merge_field_format = self.merge_field_format or '{}' + if self.merge_data is not None: - # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format) - # to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...}) + # Burst apart each to-email in personalizations[0] into a separate + # personalization, and add merge_data for that recipient + assert len(self.data["personalizations"]) == 1 + base_personalizations = self.data["personalizations"].pop() + to_list = base_personalizations.pop("to") # {email, name?} for each message.to all_fields = set() - for recipient_data in self.merge_data.values(): - all_fields = all_fields.union(recipient_data.keys()) - recipients = [email.email for email in self.to_list] + for recipient in to_list: + personalization = base_personalizations.copy() # captures cc, bcc, and any esp_extra + personalization["to"] = [recipient] + try: + recipient_data = self.merge_data[recipient["email"]] + personalization["substitutions"] = {merge_field_format.format(field): data + for field, data in recipient_data.items()} + all_fields = all_fields.union(recipient_data.keys()) + except KeyError: + pass # no merge_data for this recipient + self.data["personalizations"].append(personalization) if self.merge_field_format is None and all(field.isalnum() for field in all_fields): warnings.warn( @@ -168,143 +164,172 @@ class SendGridPayload(RequestsPayload): "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", AnymailWarning) - sub_field_fmt = self.merge_field_format or '{}' - sub_fields = {field: sub_field_fmt.format(field) for field in all_fields} - - self.smtpapi['sub'] = { - # If field data is missing for recipient, use (formatted) field as the substitution. - # (This allows default to resolve from global "section" substitutions.) - sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field]) - for recipient in recipients] - for field in all_fields - } - if self.merge_global_data is not None: - section_field_fmt = self.merge_field_format or '{}' - self.smtpapi['section'] = { - section_field_fmt.format(field): data + # (merge into any existing 'sections' from esp_extra) + self.data.setdefault("sections", {}).update({ + merge_field_format.format(field): data for field, data in self.merge_global_data.items() - } + }) + + # Confusingly, "Section tags have to be contained within a Substitution tag" + # (https://sendgrid.com/docs/API_Reference/SMTP_API/section_tags.html), + # so we need to insert a "-field-": "-field-" identity fallback for each + # missing global field in the recipient substitutions... + global_fields = [merge_field_format.format(field) + for field in self.merge_global_data.keys()] + for personalization in self.data["personalizations"]: + substitutions = personalization.setdefault("substitutions", {}) + substitutions.update({field: field for field in global_fields + if field not in substitutions}) + + if (self.merge_field_format is None and + all(field.isalnum() for field in self.merge_global_data.keys())): + warnings.warn( + "Your SendGrid global merge fields don't seem to have delimiters, " + "which can cause unexpected results with Anymail's merge_data. " + "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", + AnymailWarning) # # Payload construction # - def init_payload(self): - self.data = {} # {field: [multiple, values]} - self.files = {} - self.data['headers'] = CaseInsensitiveDict() # headers keys are case-insensitive + @staticmethod + def email_object(email, workaround_name_quote_bug=False): + """Converts ParsedEmail to SendGrid API {email, name} dict""" + obj = {"email": email.email} + if email.name: + # Work around SendGrid API bug: v3 fails to properly quote display-names + # containing commas or semicolons in personalizations (but not in from_email + # or reply_to). See https://github.com/sendgrid/sendgrid-python/issues/291. + # We can work around the problem by quoting the name for SendGrid. + if workaround_name_quote_bug: + obj["name"] = '"%s"' % rfc822_quote(email.name) + else: + obj["name"] = email.name + return obj def set_from_email(self, email): - self.data["from"] = email.email - if email.name: - self.data["fromname"] = email.name - - def set_to(self, emails): - self.to_list = emails # track for later use by build_merge_data - self.set_recipients('to', emails) + self.data["from"] = self.email_object(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: - self.data[recipient_type] = [email.email for email in emails] - empty_name = " " # SendGrid API balks on complete empty name fields - self.data[recipient_type + "name"] = [email.name or empty_name for email in emails] + workaround_name_quote_bug = self.workaround_name_quote_bug + # Normally, exactly one "personalizations" entry for all recipients + # (Exception: with merge_data; will be burst apart later.) + self.data["personalizations"][0][recipient_type] = \ + [self.email_object(email, workaround_name_quote_bug) for email in emails] self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): - self.data["subject"] = subject + if subject != "": # see note in set_text_body about template rendering + self.data["subject"] = subject def set_reply_to(self, emails): - # Note: SendGrid mangles the 'replyto' API param: it drops - # all but the last email in a multi-address replyto, and - # drops all the display names. [tested 2016-03-10] - # - # To avoid those quirks, we provide a fully-formed Reply-To - # in the custom headers, which makes it through intact. - if emails: - reply_to = ", ".join([email.address for email in emails]) - self.data["headers"]["Reply-To"] = reply_to + # SendGrid only supports a single address in the reply_to API param. + if len(emails) > 1: + self.unsupported_feature("multiple reply_to addresses") + if len(emails) > 0: + self.data["reply_to"] = self.email_object(emails[0]) def set_extra_headers(self, headers): # SendGrid requires header values to be strings -- not integers. # We'll stringify ints and floats; anything else is the caller's responsibility. - # (This field gets converted to json in self.serialize_data) self.data["headers"].update({ k: str(v) if isinstance(v, (int, float)) else v for k, v in headers.items() }) def set_text_body(self, body): - self.data["text"] = body + # Empty strings (the EmailMessage default) can cause unexpected SendGrid + # template rendering behavior, such as ignoring the HTML template and + # rendering HTML from the plaintext template instead. + # Treat an empty string as a request to omit the body + # (which means use the template content if present.) + if body != "": + self.data.setdefault("content", []).append({ + "type": "text/plain", + "value": body, + }) def set_html_body(self, body): - if "html" in self.data: - # second html body could show up through multiple alternatives, or html body + alternative - self.unsupported_feature("multiple html parts") - self.data["html"] = body + # SendGrid's API permits multiple html bodies + # "If you choose to include the text/plain or text/html mime types, they must be + # the first indices of the content array in the order text/plain, text/html." + if body != "": # see note in set_text_body about template rendering + self.data.setdefault("content", []).append({ + "type": "text/html", + "value": body, + }) + + def add_alternative(self, content, mimetype): + # SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API + self.data.setdefault("content", []).append({ + "type": mimetype, + "value": content, + }) def add_attachment(self, attachment): - filename = attachment.name or "" + att = { + "content": attachment.b64content, + "type": attachment.mimetype, + "filename": attachment.name or '', # required -- submit empty string if unknown + } if attachment.inline: - filename = filename or attachment.cid # must have non-empty name for the cid matching - content_field = "content[%s]" % filename - self.data[content_field] = attachment.cid - - files_field = "files[%s]" % filename - if files_field in self.files: - # It's possible SendGrid could actually handle this case (needs testing), - # but requests doesn't seem to accept a list of tuples for a files field. - # (See the MailgunBackend version for a different approach that might work.) - self.unsupported_feature( - "multiple attachments with the same filename ('%s')" % filename if filename - else "multiple unnamed attachments") - - self.files[files_field] = (filename, attachment.content, attachment.mimetype) + att["disposition"] = "inline" + att["content_id"] = attachment.cid + self.data.setdefault("attachments", []).append(att) def set_metadata(self, metadata): - self.smtpapi['unique_args'] = metadata + # SendGrid requires custom_args values to be strings -- not integers. + # (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null} + # if they're not.) + # We'll stringify ints and floats; anything else is the caller's responsibility. + self.data["custom_args"] = { + k: str(v) if isinstance(v, (int, float)) else v + for k, v in metadata.items() + } def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; SendGrid expects unix timestamp - self.smtpapi["send_at"] = int(timestamp(send_at)) # strip microseconds + self.data["send_at"] = int(timestamp(send_at)) # strip microseconds def set_tags(self, tags): - self.smtpapi["category"] = tags - - def add_filter(self, filter_name, setting, val): - self.smtpapi.setdefault('filters', {})\ - .setdefault(filter_name, {})\ - .setdefault('settings', {})[setting] = val + self.data["categories"] = tags def set_track_clicks(self, track_clicks): - self.add_filter('clicktrack', 'enable', int(track_clicks)) + self.data.setdefault("tracking_settings", {})["click_tracking"] = { + "enable": track_clicks, + } def set_track_opens(self, track_opens): - # SendGrid's opentrack filter also supports a "replace" - # parameter, which Anymail doesn't offer directly. - # (You could add it through esp_extra.) - self.add_filter('opentrack', 'enable', int(track_opens)) + # SendGrid's open_tracking setting also supports a "substitution_tag" parameter, + # which Anymail doesn't offer directly. (You could add it through esp_extra.) + self.data.setdefault("tracking_settings", {})["open_tracking"] = { + "enable": track_opens, + } def set_template_id(self, template_id): - self.add_filter('templates', 'enable', 1) - self.add_filter('templates', 'template_id', template_id) - # Must ensure text and html are non-empty, or template parts won't render. - # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates - if not self.data.get("text", ""): - self.data["text"] = " " - if not self.data.get("html", ""): - self.data["html"] = " " + self.data["template_id"] = template_id def set_merge_data(self, merge_data): - # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format. + # Becomes personalizations[...]['substitutions'] in build_merge_data, + # after we know recipients and merge_field_format. self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): - # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format. + # Becomes data['section'] in build_merge_data, after we know merge_field_format. self.merge_global_data = merge_global_data def set_esp_extra(self, extra): - self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format) - self.data.update(extra) + self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format) + if "x-smtpapi" in extra: + raise AnymailConfigurationError( + "You are attempting to use SendGrid v2 API-style x-smtpapi params " + "with the SendGrid v3 API. Please update your `esp_extra` to the new API, " + "or use 'anymail.backends.sendgrid_v2.EmailBackend' for the old API." + ) + update_deep(self.data, extra) + diff --git a/anymail/backends/sendgrid_v2.py b/anymail/backends/sendgrid_v2.py new file mode 100644 index 0000000..045acca --- /dev/null +++ b/anymail/backends/sendgrid_v2.py @@ -0,0 +1,315 @@ +import warnings + +from django.core.mail import make_msgid +from requests.structures import CaseInsensitiveDict + +from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning +from ..message import AnymailRecipientStatus +from ..utils import get_anymail_setting, timestamp + +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class EmailBackend(AnymailRequestsBackend): + """ + SendGrid v2 API Email Backend (deprecated) + """ + + esp_name = "SendGrid" + + def __init__(self, **kwargs): + """Init options from Django settings""" + # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD + esp_name = self.esp_name + self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, + default=None, allow_bare=True) + self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, + default=None, allow_bare=True) + self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, + default=None, allow_bare=True) + if self.api_key is None and (self.username is None or self.password is None): + raise AnymailConfigurationError( + "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and " + "SENDGRID_PASSWORD in your Django ANYMAIL settings." + ) + + self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name, + kwargs=kwargs, default=True) + self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name, + kwargs=kwargs, default=None) + + # This is SendGrid's older Web API v2 + api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, + default="https://api.sendgrid.com/api/") + if not api_url.endswith("/"): + api_url += "/" + super(EmailBackend, self).__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return SendGridPayload(message, defaults, self) + + def parse_recipient_status(self, response, payload, message): + parsed_response = self.deserialize_json_response(response, payload, message) + try: + sendgrid_message = parsed_response["message"] + except (KeyError, TypeError): + raise AnymailRequestsAPIError("Invalid SendGrid API response format", + email_message=message, payload=payload, response=response) + if sendgrid_message != "success": + errors = parsed_response.get("errors", []) + raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors), + email_message=message, payload=payload, response=response) + # Simulate a per-recipient status of "queued": + status = AnymailRecipientStatus(message_id=payload.message_id, status="queued") + return {recipient.email: status for recipient in payload.all_recipients} + + +class SendGridPayload(RequestsPayload): + """ + SendGrid v2 API Mail Send payload + """ + + def __init__(self, message, defaults, backend, *args, **kwargs): + self.all_recipients = [] # used for backend.parse_recipient_status + self.generate_message_id = backend.generate_message_id + self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers + self.smtpapi = {} # SendGrid x-smtpapi field + self.to_list = [] # needed for build_merge_data + self.merge_field_format = backend.merge_field_format + self.merge_data = None # late-bound per-recipient data + self.merge_global_data = None + + http_headers = kwargs.pop('headers', {}) + query_params = kwargs.pop('params', {}) + if backend.api_key is not None: + http_headers['Authorization'] = 'Bearer %s' % backend.api_key + else: + query_params['api_user'] = backend.username + query_params['api_key'] = backend.password + super(SendGridPayload, self).__init__(message, defaults, backend, + params=query_params, headers=http_headers, + *args, **kwargs) + + def get_api_endpoint(self): + return "mail.send.json" + + def serialize_data(self): + """Performs any necessary serialization on self.data, and returns the result.""" + + if self.generate_message_id: + self.ensure_message_id() + + self.build_merge_data() + if self.merge_data is not None: + # Move the 'to' recipients to smtpapi, so SG does batch send + # (else all recipients would see each other's emails). + # Regular 'to' must still be a valid email (even though "ignored")... + # we use the from_email as recommended by SG support + # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250) + self.smtpapi['to'] = [email.address for email in self.to_list] + self.data['to'] = [self.data['from']] + self.data['toname'] = [self.data.get('fromname', " ")] + + # Serialize x-smtpapi to json: + if len(self.smtpapi) > 0: + # If esp_extra was also used to set x-smtpapi, need to merge it + if "x-smtpapi" in self.data: + esp_extra_smtpapi = self.data["x-smtpapi"] + for key, value in esp_extra_smtpapi.items(): + if key == "filters": + # merge filters (else it's difficult to mix esp_extra with other features) + self.smtpapi.setdefault(key, {}).update(value) + else: + # all other keys replace any current value + self.smtpapi[key] = value + self.data["x-smtpapi"] = self.serialize_json(self.smtpapi) + elif "x-smtpapi" in self.data: + self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"]) + + # Serialize extra headers to json: + headers = self.data["headers"] + self.data["headers"] = self.serialize_json(dict(headers.items())) + + return self.data + + def ensure_message_id(self): + """Ensure message has a known Message-ID for later event tracking""" + headers = self.data["headers"] + if "Message-ID" not in headers: + # Only make our own if caller hasn't already provided one + headers["Message-ID"] = self.make_message_id() + self.message_id = headers["Message-ID"] + + # Workaround for missing message ID (smtp-id) in SendGrid engagement events + # (click and open tracking): because unique_args get merged into the raw event + # record, we can supply the 'smtp-id' field for any events missing it. + self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id + + def make_message_id(self): + """Returns a Message-ID that could be used for this payload + + Tries to use the from_email's domain as the Message-ID's domain + """ + try: + _, domain = self.data["from"].split("@") + except (AttributeError, KeyError, TypeError, ValueError): + domain = None + return make_msgid(domain=domain) + + def build_merge_data(self): + """Set smtpapi['sub'] and ['section']""" + if self.merge_data is not None: + # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}} (merge_data format) + # to {a: [A1, A2], b: [B1, ""]} ({field: [data in to-list order], ...}) + all_fields = set() + for recipient_data in self.merge_data.values(): + all_fields = all_fields.union(recipient_data.keys()) + recipients = [email.email for email in self.to_list] + + if self.merge_field_format is None and all(field.isalnum() for field in all_fields): + warnings.warn( + "Your SendGrid merge fields don't seem to have delimiters, " + "which can cause unexpected results with Anymail's merge_data. " + "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", + AnymailWarning) + + sub_field_fmt = self.merge_field_format or '{}' + sub_fields = {field: sub_field_fmt.format(field) for field in all_fields} + + self.smtpapi['sub'] = { + # If field data is missing for recipient, use (formatted) field as the substitution. + # (This allows default to resolve from global "section" substitutions.) + sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field]) + for recipient in recipients] + for field in all_fields + } + + if self.merge_global_data is not None: + section_field_fmt = self.merge_field_format or '{}' + self.smtpapi['section'] = { + section_field_fmt.format(field): data + for field, data in self.merge_global_data.items() + } + + # + # Payload construction + # + + def init_payload(self): + self.data = {} # {field: [multiple, values]} + self.files = {} + self.data['headers'] = CaseInsensitiveDict() # headers keys are case-insensitive + + def set_from_email(self, email): + self.data["from"] = email.email + if email.name: + self.data["fromname"] = email.name + + def set_to(self, emails): + self.to_list = emails # track for later use by build_merge_data + self.set_recipients('to', emails) + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + self.data[recipient_type] = [email.email for email in emails] + empty_name = " " # SendGrid API balks on complete empty name fields + self.data[recipient_type + "name"] = [email.name or empty_name for email in emails] + self.all_recipients += emails # used for backend.parse_recipient_status + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails): + # Note: SendGrid mangles the 'replyto' API param: it drops + # all but the last email in a multi-address replyto, and + # drops all the display names. [tested 2016-03-10] + # + # To avoid those quirks, we provide a fully-formed Reply-To + # in the custom headers, which makes it through intact. + if emails: + reply_to = ", ".join([email.address for email in emails]) + self.data["headers"]["Reply-To"] = reply_to + + def set_extra_headers(self, headers): + # SendGrid requires header values to be strings -- not integers. + # We'll stringify ints and floats; anything else is the caller's responsibility. + # (This field gets converted to json in self.serialize_data) + self.data["headers"].update({ + k: str(v) if isinstance(v, (int, float)) else v + for k, v in headers.items() + }) + + def set_text_body(self, body): + self.data["text"] = body + + def set_html_body(self, body): + if "html" in self.data: + # second html body could show up through multiple alternatives, or html body + alternative + self.unsupported_feature("multiple html parts") + self.data["html"] = body + + def add_attachment(self, attachment): + filename = attachment.name or "" + if attachment.inline: + filename = filename or attachment.cid # must have non-empty name for the cid matching + content_field = "content[%s]" % filename + self.data[content_field] = attachment.cid + + files_field = "files[%s]" % filename + if files_field in self.files: + # It's possible SendGrid could actually handle this case (needs testing), + # but requests doesn't seem to accept a list of tuples for a files field. + # (See the MailgunBackend version for a different approach that might work.) + self.unsupported_feature( + "multiple attachments with the same filename ('%s')" % filename if filename + else "multiple unnamed attachments") + + self.files[files_field] = (filename, attachment.content, attachment.mimetype) + + def set_metadata(self, metadata): + self.smtpapi['unique_args'] = metadata + + def set_send_at(self, send_at): + # Backend has converted pretty much everything to + # a datetime by here; SendGrid expects unix timestamp + self.smtpapi["send_at"] = int(timestamp(send_at)) # strip microseconds + + def set_tags(self, tags): + self.smtpapi["category"] = tags + + def add_filter(self, filter_name, setting, val): + self.smtpapi.setdefault('filters', {})\ + .setdefault(filter_name, {})\ + .setdefault('settings', {})[setting] = val + + def set_track_clicks(self, track_clicks): + self.add_filter('clicktrack', 'enable', int(track_clicks)) + + def set_track_opens(self, track_opens): + # SendGrid's opentrack filter also supports a "replace" + # parameter, which Anymail doesn't offer directly. + # (You could add it through esp_extra.) + self.add_filter('opentrack', 'enable', int(track_opens)) + + def set_template_id(self, template_id): + self.add_filter('templates', 'enable', 1) + self.add_filter('templates', 'template_id', template_id) + # Must ensure text and html are non-empty, or template parts won't render. + # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates + if not self.data.get("text", ""): + self.data["text"] = " " + if not self.data.get("html", ""): + self.data["html"] = " " + + def set_merge_data(self, merge_data): + # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format. + self.merge_data = merge_data + + def set_merge_global_data(self, merge_global_data): + # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format. + self.merge_global_data = merge_global_data + + def set_esp_extra(self, extra): + self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format) + self.data.update(extra) diff --git a/anymail/utils.py b/anymail/utils.py index a4a4483..fdda982 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -1,5 +1,6 @@ import mimetypes from base64 import b64encode +from collections import Mapping, MutableMapping from datetime import datetime from email.mime.base import MIMEBase from email.utils import formatdate, getaddresses, unquote @@ -95,6 +96,20 @@ def getfirst(dct, keys, default=UNSET): return default +def update_deep(dct, other): + """Merge (recursively) keys and values from dict other into dict dct + + Works with dict-like objects: dct (and descendants) can be any MutableMapping, + and other can be any Mapping + """ + for key, value in other.items(): + if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping): + update_deep(dct[key], value) + else: + dct[key] = value + # (like dict.update(), no return value) + + def parse_one_addr(address): # This is email.utils.parseaddr, but without silently returning # partial content if there are commas or parens in the string: diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index 6cc9dbf..4844ad8 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -3,12 +3,25 @@ SendGrid ======== -Anymail integrates with the `SendGrid`_ email service, -using their `Web API v2`_. (Their v3 API does not support sending mail, -but the v3 API calls *do* get information about mail sent through v2.) +Anymail integrates with the `SendGrid`_ email service, using their `Web API v3`_. + +.. versionchanged:: 0.8 + + Earlier Anymail releases used SendGrid's v2 API. If you are upgrading, + please review the :ref:`porting notes `. + +.. important:: + + **Troubleshooting:** + If your SendGrid messages aren't being delivered as expected, be sure to look for + "drop" events in your SendGrid `activity feed`_. + + SendGrid detects certain types of errors only *after* the send API call appears + to succeed, and reports these errors as drop events. .. _SendGrid: https://sendgrid.com/ -.. _Web API v2: https://sendgrid.com/docs/API_Reference/Web_API/mail.html +.. _Web API v3: https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html +.. _activity feed: https://app.sendgrid.com/email_activity?events=drops Settings @@ -33,8 +46,7 @@ their name with an uppercase "G", so Anymail does too.) A SendGrid API key with "Mail Send" permission. (Manage API keys in your `SendGrid API key settings`_.) -Either an API key or both :setting:`SENDGRID_USERNAME ` -and :setting:`SENDGRID_PASSWORD ` are required. +Required. .. code-block:: python @@ -50,34 +62,6 @@ nor ``ANYMAIL_SENDGRID_API_KEY`` is set. .. _SendGrid API key settings: https://app.sendgrid.com/settings/api_keys -.. setting:: ANYMAIL_SENDGRID_USERNAME -.. setting:: ANYMAIL_SENDGRID_PASSWORD - -.. rubric:: SENDGRID_USERNAME and SENDGRID_PASSWORD - -SendGrid credentials with the "Mail" permission. You should **not** -use the username/password that you use to log into SendGrid's -dashboard. Create credentials specifically for sending mail in the -`SendGrid credentials settings`_. - - .. code-block:: python - - ANYMAIL = { - ... - "SENDGRID_USERNAME": "", - "SENDGRID_PASSWORD": "", - } - -Either username/password or :setting:`SENDGRID_API_KEY ` -are required (but not both). - -Anymail will also look for ``SENDGRID_USERNAME`` and ``SENDGRID_PASSWORD`` at the -root of the settings file if neither ``ANYMAIL["SENDGRID_USERNAME"]`` -nor ``ANYMAIL_SENDGRID_USERNAME`` is set. - -.. _SendGrid credentials settings: https://app.sendgrid.com/settings/credentials - - .. setting:: ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID .. rubric:: SENDGRID_GENERATE_MESSAGE_ID @@ -121,9 +105,9 @@ below. .. rubric:: SENDGRID_API_URL -The base url for calling the SendGrid v2 API. +The base url for calling the SendGrid API. -The default is ``SENDGRID_API_URL = "https://api.sendgrid.com/api/"`` +The default is ``SENDGRID_API_URL = "https://api.sendgrid.com/v3/"`` (It's unlikely you would need to change this.) @@ -134,28 +118,28 @@ esp_extra support To use SendGrid features not directly supported by Anymail, you can set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to -a `dict` of parameters for SendGrid's `mail.send API`_. Any keys in -your :attr:`esp_extra` dict will override Anymail's normal values -for that parameter, except that `'x-smtpapi'` will be merged. +a `dict` of parameters for SendGrid's `v3 Mail Send API`_. +Your :attr:`esp_extra` dict will be deeply merged into the +parameters Anymail has constructed for the send, with `esp_extra` +having precedence in conflicts. Example: .. code-block:: python + message.open_tracking = True message.esp_extra = { - 'x-smtpapi': { - "asm_group": 1, # Assign SendGrid unsubscribe group for this message - "asm_groups_to_display": [1, 2, 3], - "filters": { - "subscriptiontrack": { # Insert SendGrid subscription management links - "settings": { - "text/html": "If you would like to unsubscribe <% click here %>.", - "text/plain": "If you would like to unsubscribe click here: <% %>.", - "enable": 1 - } - } - } - } + "asm": { # SendGrid subscription management + "group_id": 1, + "groups_to_display": [1, 2, 3], + }, + "tracking_settings": { + "open_tracking": { + # Anymail will automatically set `"enable": True` here, + # based on message.open_tracking. + "substitution_tag": "%%OPEN_TRACKING_PIXEL%%", + }, + }, } @@ -164,22 +148,14 @@ Example: messages.) -.. _mail.send API: https://sendgrid.com/docs/API_Reference/Web_API/mail.html#-send +.. _v3 Mail Send API: + https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html#-Request-Body-Parameters Limitations and quirks ---------------------- -**Duplicate attachment filenames** - Anymail is not capable of communicating multiple attachments with - the same filename to SendGrid. (This also applies to multiple attachments - with *no* filename, though not to inline images.) - - If you are sending multiple attachments on a single message, - make sure each one has a unique, non-empty filename. - - .. _sendgrid-message-id: **Message-ID** @@ -204,6 +180,14 @@ Limitations and quirks To disable all of these Message-ID workarounds, set :setting:`ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID` to False in your settings. +**Single Reply-To** + SendGrid's v3 API only supports a single Reply-To address (and blocks + a workaround that was possible with the v2 API). + + If your message has multiple reply addresses, you'll get an + :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or + if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, + Anymail will use only the first one. **Invalid Addresses** SendGrid will accept *and send* just about anything as @@ -236,8 +220,7 @@ message attributes. message = EmailMessage( ... - subject="", # don't add any additional subject content to the template - body="", # (same thing for additional body content) + # omit subject and body (or set to None) to use template content to=["alice@example.com", "Bob "] ) message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" # SendGrid id @@ -273,19 +256,15 @@ There are three ways you can do this: When you supply per-recipient :attr:`~anymail.message.AnymailMessage.merge_data`, Anymail automatically changes how it communicates the "to" list to SendGrid, so that -so that each recipient sees only their own email address. (Anymail moves the recipients -from top-level "to" and "toname" API parameters into the "x-smtpapi" section "to" list.) +so that each recipient sees only their own email address. (Anymail creates a separate +"personalization" for each recipient in the "to" list; any cc's or bcc's will be +duplicated for *every* to-recipient.) SendGrid templates allow you to mix your EmailMessage's `subject` and `body` with the template subject and body (by using `<%subject%>` and `<%body%>` in your SendGrid template definition where you want the message-specific versions to appear). If you don't want to supply any additional subject or body content -from your Django app, set those EmailMessage attributes to empty strings. - -(Anymail will convert empty text and HTML bodies to single spaces whenever -:attr:`~anymail.message.AnymailMessage.template_id` is set, to ensure the -plaintext and HTML from your template are present in your outgoing email. -This works around a `limitation in SendGrid's template rendering`_.) +from your Django app, set those EmailMessage attributes to empty strings or `None`. See the `SendGrid's template overview`_ and `transactional template docs`_ for more information. @@ -294,8 +273,6 @@ for more information. https://sendgrid.com/docs/User_Guide/Transactional_Templates/index.html .. _transactional template docs: https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html -.. _limitation in SendGrid's template rendering: - https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates .. _sendgrid-webhooks: @@ -324,3 +301,186 @@ for each event in the batch.) .. _SendGrid mail settings: https://app.sendgrid.com/settings/mail_settings .. _Sendgrid event: https://sendgrid.com/docs/API_Reference/Webhooks/event.html + + + +.. _sendgrid-v3-upgrade: + +Upgrading to SendGrid's v3 API +------------------------------ + +Anymail v0.8 switched to SendGrid's preferred v3 send API. +(Earlier Anymail releases used their v2 API.) + +For many Anymail projects, this change will be entirely transparent. +(Anymail's whole reason for existence is abstracting ESP APIs, +so that your own code doesn't need to worry about the details.) + +There are three cases where SendGrid has changed features +that would require updates to your code: + +1. If you are using SendGrid's username/password auth (your settings + include :setting:`SENDGRID_USERNAME ` + and :setting:`SENDGRID_PASSWORD `), + you must switch to an API key. + See :setting:`SENDGRID_API_KEY `. + + (If you are already using a SendGrid API key with v2, it should + work just fine with v3.) + +2. If you are using Anymail's + :attr:`~anymail.message.AnymailMessage.esp_extra` attribute + to supply API-specific parameters, the format has changed. + + Search your code for "esp_extra" (e.g., `git grep esp_extra`) + to determine whether this affects you. (Anymail's + `"merge_field_format"` is unchanged, so if that's the only + thing you have in esp_extra, no changes are needed.) + + The new API format is considerably simpler and more logical. + See :ref:`sendgrid-esp-extra` below for examples of the + new format and a link to relevant SendGrid docs. + + Anymail will raise an error if it detects an attempt to use + the v2-only `"x-smtpapi"` settings in esp_extra when sending. + +3. If you send messages with multiple Reply-To addresses, SendGrid + no longer supports this. (Multiple reply emails in a single + message are not common.) + + Anymail will raise an error if you attempt to send a message with + multiple Reply-To emails. (You can suppress the error with + :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, which will + ignore all but the first reply address.) + + +As an alternative, Anymail (for the time being) still includes +a copy of the SendGrid v2 backend. See :ref:`sendgrid-v2-backend` +below if you'd prefer to stay on the older SendGrid API. + + +.. _sendgrid-v2-backend: + +Legacy v2 API support +--------------------- + +.. versionchanged:: 0.8 + +Anymail v0.8 switched to SendGrid's v3 Web API in its primary SendGrid +email backend. SendGrid `encourages`_ all users to migrate to their v3 API. + +For Anymail users who still need it, a legacy backend that calls SendGrid's +earlier `Web API v2 Mail Send`_ remains available. Be aware that v2 support +is considered deprecated and may be removed in a future Anymail release. + +.. _encourages: + https://sendgrid.com/docs/Classroom/Send/v3_Mail_Send/how_to_migrate_from_v2_to_v3_mail_send.html +.. _Web API v2 Mail Send: + https://sendgrid.com/docs/API_Reference/Web_API/mail.html + + +To use Anymail's SendGrid v2 backend, edit your settings.py: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.sendgrid_v2.EmailBackend" + ANYMAIL = { + "SENDGRID_API_KEY": "", + } + +The same :setting:`SENDGRID_API_KEY ` will work +with either Anymail's v2 or v3 SendGrid backend. + +Nearly all of the documentation above for Anymail's v3 SendGrid backend +also applies to the v2 backend, with the following changes: + +.. setting:: ANYMAIL_SENDGRID_USERNAME +.. setting:: ANYMAIL_SENDGRID_PASSWORD + +.. rubric:: Username/password auth (SendGrid v2 only) + +SendGrid v2 allows a username/password instead of an API key +(though SendGrid encourages API keys for all new installations). +If you must use username/password auth, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.sendgrid_v2.EmailBackend" + ANYMAIL = { + "SENDGRID_USERNAME": "", + "SENDGRID_PASSWORD": "", + # And leave out "SENDGRID_API_KEY" + } + +This is **not** the username/password that you use to log into SendGrid's +dashboard. Create credentials specifically for sending mail in the +`SendGrid credentials settings`_. + +Either username/password or :setting:`SENDGRID_API_KEY ` +are required (but not both). + +Anymail will also look for ``SENDGRID_USERNAME`` and ``SENDGRID_PASSWORD`` at the +root of the settings file if neither ``ANYMAIL["SENDGRID_USERNAME"]`` +nor ``ANYMAIL_SENDGRID_USERNAME`` is set. + +.. _SendGrid credentials settings: https://app.sendgrid.com/settings/credentials + + +.. rubric:: Duplicate attachment filenames (SendGrid v2 limitation) + +Anymail is not capable of communicating multiple attachments with +the same filename to the SendGrid v2 API. (This also applies to multiple +attachments with *no* filename, though not to inline images.) + +If you are sending multiple attachments on a single message, +make sure each one has a unique, non-empty filename. + + +.. rubric:: Message bodies with ESP templates (SendGrid v2 quirk) + +Anymail's SendGrid v2 backend will convert empty text and HTML bodies to single spaces whenever +:attr:`~anymail.message.AnymailMessage.template_id` is set, to ensure the +plaintext and HTML from your template are present in your outgoing email. +This works around a `limitation in SendGrid's template rendering`_. + +.. _limitation in SendGrid's template rendering: + https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates + + +.. rubric:: Multiple Reply-To addresses (SendGrid v2 only) + +Unlike SendGrid's v3 API, Anymail is able to support multiple +Reply-To addresses with their v2 API. + + +.. rubric:: esp_extra with SendGrid v2 + +Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra` attribute +is merged directly with the API parameters, so the format varies +between SendGrid's v2 and v3 APIs. With the v2 API, most interesting +settings appear beneath `'x-smtpapi'`. Example: + + .. code-block:: python + + message.esp_extra = { + 'x-smtpapi': { # for SendGrid v2 API + "asm_group": 1, # Assign SendGrid unsubscribe group for this message + "asm_groups_to_display": [1, 2, 3], + "filters": { + "subscriptiontrack": { # Insert SendGrid subscription management links + "settings": { + "text/html": "If you would like to unsubscribe <% click here %>.", + "text/plain": "If you would like to unsubscribe click here: <% %>.", + "enable": 1 + } + } + } + } + } + +The value of :attr:`esp_extra` should be a `dict` of parameters for SendGrid's +`v2 mail.send API`_. Any keys in the dict will override Anymail's normal values +for that parameter, except that `'x-smtpapi'` will be merged. + +.. _v2 mail.send API: + https://sendgrid.com/docs/API_Reference/Web_API/mail.html#-send diff --git a/tests/mock_requests_backend.py b/tests/mock_requests_backend.py index 33ab366..0b80966 100644 --- a/tests/mock_requests_backend.py +++ b/tests/mock_requests_backend.py @@ -17,6 +17,7 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): """TestCase that mocks API calls through requests""" DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}""" + DEFAULT_STATUS_CODE = 200 # most APIs use '200 OK' for success class MockResponse(requests.Response): """requests.request return value mock sufficient for testing""" @@ -33,7 +34,7 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): self.addCleanup(self.patch_request.stop) self.set_mock_response() - def set_mock_response(self, status_code=200, raw=UNSET, encoding='utf-8'): + def set_mock_response(self, status_code=DEFAULT_STATUS_CODE, raw=UNSET, encoding='utf-8'): if raw is UNSET: raw = self.DEFAULT_RAW_RESPONSE mock_response = self.MockResponse(status_code, raw, encoding) diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index a476826..927ff3d 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -81,12 +81,12 @@ class BackendSettingsTests(SimpleTestCase, AnymailTestMixin): # (so not def test_username_password_kwargs_overrides(self): """Overrides for 'username' and 'password' should work like other overrides""" # These are special-cased because of default args in Django core mail functions. - # (Use the SendGrid backend, which has settings named 'username' and 'password'.) - backend = get_connection('anymail.backends.sendgrid.SendGridBackend') + # (Use the SendGrid v2 backend, which has settings named 'username' and 'password'.) + backend = get_connection('anymail.backends.sendgrid_v2.EmailBackend') self.assertEqual(backend.username, 'username_from_settings') self.assertEqual(backend.password, 'password_from_settings') - backend = get_connection('anymail.backends.sendgrid.SendGridBackend', + backend = get_connection('anymail.backends.sendgrid_v2.EmailBackend', username='username_from_kwargs', password='password_from_kwargs') self.assertEqual(backend.username, 'username_from_kwargs') self.assertEqual(backend.password, 'password_from_kwargs') diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index f0b764d..5de7ad3 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -import json +from base64 import b64encode, b64decode from calendar import timegm from datetime import date, datetime from decimal import Decimal @@ -8,12 +8,12 @@ from email.mime.base import MIMEBase from email.mime.image import MIMEImage from django.core import mail -from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase from django.test.utils import override_settings from django.utils.timezone import get_fixed_timezone, override as override_current_timezone -from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature, AnymailWarning +from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError, + AnymailUnsupportedFeature, AnymailWarning) from anymail.message import attach_inline_image_file from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin @@ -23,20 +23,14 @@ from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAM @override_settings(EMAIL_BACKEND='anymail.backends.sendgrid.SendGridBackend', ANYMAIL={'SENDGRID_API_KEY': 'test_api_key'}) class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase): - DEFAULT_RAW_RESPONSE = b"""{ - "message": "success" - }""" + DEFAULT_RAW_RESPONSE = b"" # SendGrid v3 success responses are empty + DEFAULT_STATUS_CODE = 202 # SendGrid v3 uses '202 Accepted' for success (in most cases) def setUp(self): super(SendGridBackendMockAPITestCase, self).setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) - def get_smtpapi(self): - """Returns the x-smtpapi data passed to the mock requests call""" - data = self.get_api_call_data() - return json.loads(data["x-smtpapi"]) - class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): """Test backend support for Django standard email features""" @@ -45,39 +39,22 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): """Test basic API for simple send""" mail.send_mail('Subject here', 'Here is the message.', 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/api/mail.send.json') + self.assert_esp_called('https://api.sendgrid.com/v3/mail/send') http_headers = self.get_api_call_headers() self.assertEqual(http_headers["Authorization"], "Bearer test_api_key") + self.assertEqual(http_headers["Content-Type"], "application/json") - query = self.get_api_call_params(required=False) - if query: - self.assertNotIn('api_user', query) - self.assertNotIn('api_key', query) - - data = self.get_api_call_data() + data = self.get_api_call_json() self.assertEqual(data['subject'], "Subject here") - self.assertEqual(data['text'], "Here is the message.") - self.assertEqual(data['from'], "from@sender.example.com") - self.assertEqual(data['to'], ["to@example.com"]) + self.assertEqual(data['content'], [{'type': "text/plain", 'value': "Here is the message."}]) + self.assertEqual(data['from'], {'email': "from@sender.example.com"}) + self.assertEqual(data['personalizations'], [{ + 'to': [{'email': "to@example.com"}], + }]) # make sure backend assigned a Message-ID for event tracking - email_headers = json.loads(data['headers']) - self.assertRegex(email_headers['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain - # make sure we added the Message-ID to unique_args for event notification - smtpapi = self.get_smtpapi() - self.assertEqual(email_headers['Message-ID'], smtpapi['unique_args']['smtp-id']) - - @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) - def test_user_pass_auth(self): - """Make sure alternative USERNAME/PASSWORD auth works""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/api/mail.send.json') - query = self.get_api_call_params() - self.assertEqual(query['api_user'], 'sg_username') - self.assertEqual(query['api_key'], 'sg_password') - http_headers = self.get_api_call_headers(required=False) - if http_headers: - self.assertNotIn('Authorization', http_headers) + self.assertRegex(data['headers']['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain + # make sure we added the Message-ID to custom_args for event notification + self.assertEqual(data['headers']['Message-ID'], data['custom_args']['smtp-id']) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -90,15 +67,24 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): cc=['Carbon Copy ', 'cc2@example.com'], bcc=['Blind Copy ', 'bcc2@example.com']) msg.send() - data = self.get_api_call_data() - self.assertEqual(data['from'], "from@example.com") - self.assertEqual(data['fromname'], "From Name") - self.assertEqual(data['to'], ['to1@example.com', 'to2@example.com']) - self.assertEqual(data['toname'], ['Recipient #1', ' ']) # note space -- SendGrid balks on '' - self.assertEqual(data['cc'], ['cc1@example.com', 'cc2@example.com']) - self.assertEqual(data['ccname'], ['Carbon Copy', ' ']) - self.assertEqual(data['bcc'], ['bcc1@example.com', 'bcc2@example.com']) - self.assertEqual(data['bccname'], ['Blind Copy', ' ']) + data = self.get_api_call_json() + self.assertEqual(data['from'], {'email': "from@example.com", 'name': "From Name"}) + + # single message (single "personalization") sent to all those recipients + # (note workaround for SendGrid v3 API bug quoting display-name in personalizations) + self.assertEqual(len(data['personalizations']), 1) + self.assertEqual(data['personalizations'][0]['to'], [ + {'name': '"Recipient #1"', 'email': 'to1@example.com'}, + {'email': 'to2@example.com'} + ]) + self.assertEqual(data['personalizations'][0]['cc'], [ + {'name': '"Carbon Copy"', 'email': 'cc1@example.com'}, + {'email': 'cc2@example.com'} + ]) + self.assertEqual(data['personalizations'][0]['bcc'], [ + {'name': '"Blind Copy"', 'email': 'bcc1@example.com'}, + {'email': 'bcc2@example.com'} + ]) def test_email_message(self): email = mail.EmailMessage( @@ -110,24 +96,27 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): 'X-MyHeader': 'my value', 'Message-ID': ''}) # should override backend msgid email.send() - data = self.get_api_call_data() + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [{ + 'to': [{'email': "to1@example.com"}, + {'email': "to2@example.com", 'name': '"Also To"'}], + 'cc': [{'email': "cc1@example.com"}, + {'email': "cc2@example.com", 'name': '"Also CC"'}], + 'bcc': [{'email': "bcc1@example.com"}, + {'email': "bcc2@example.com", 'name': '"Also BCC"'}], + }]) + + self.assertEqual(data['from'], {'email': "from@example.com"}) self.assertEqual(data['subject'], "Subject") - self.assertEqual(data['text'], "Body goes here") - self.assertEqual(data['from'], "from@example.com") - self.assertEqual(data['to'], ['to1@example.com', 'to2@example.com']) - self.assertEqual(data['toname'], [' ', 'Also To']) - self.assertEqual(data['bcc'], ['bcc1@example.com', 'bcc2@example.com']) - self.assertEqual(data['bccname'], [' ', 'Also BCC']) - self.assertEqual(data['cc'], ['cc1@example.com', 'cc2@example.com']) - self.assertEqual(data['ccname'], [' ', 'Also CC']) - self.assertJSONEqual(data['headers'], { - 'Message-ID': '', - 'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', + self.assertEqual(data['content'], [{'type': "text/plain", 'value': "Body goes here"}]) + self.assertEqual(data['reply_to'], {'email': "another@example.com"}) + self.assertEqual(data['headers'], { + 'X-MyHeader': "my value", + 'Message-ID': "", }) - # make sure custom Message-ID also added to unique_args - self.assertJSONEqual(data['x-smtpapi'], { - 'unique_args': {'smtp-id': ''} + # make sure custom Message-ID also added to custom_args + self.assertEqual(data['custom_args'], { + 'smtp-id': "", }) def test_html_message(self): @@ -137,29 +126,33 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): 'from@example.com', ['to@example.com']) email.attach_alternative(html_content, "text/html") email.send() - data = self.get_api_call_data() - self.assertEqual(data['text'], text_content) - self.assertEqual(data['html'], html_content) + data = self.get_api_call_json() + # SendGrid requires content in text, html order: + self.assertEqual(len(data['content']), 2) + self.assertEqual(data['content'][0], {'type': "text/plain", 'value': text_content}) + self.assertEqual(data['content'][1], {'type': "text/html", 'value': html_content}) # Don't accidentally send the html part as an attachment: - files = self.get_api_call_files(required=False) - self.assertIsNone(files) + self.assertNotIn('attachments', data) def test_html_only_message(self): html_content = '

This is an important message.

' email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) email.content_subtype = "html" # Main content is now text/html email.send() - data = self.get_api_call_data() - self.assertNotIn('text', data) - self.assertEqual(data['html'], html_content) + data = self.get_api_call_json() + self.assertEqual(len(data['content']), 1) + self.assertEqual(data['content'][0], {'type': "text/html", 'value': html_content}) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} + self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, + 'Reply-To': '"Do Not Reply" '} self.message.send() - data = self.get_api_call_data() - headers = json.loads(data['headers']) - self.assertEqual(headers['X-Custom'], 'string') - self.assertEqual(headers['X-Num'], '123') # number converted to string (per SendGrid requirement) + data = self.get_api_call_json() + self.assertEqual(data['headers']['X-Custom'], 'string') + self.assertEqual(data['headers']['X-Num'], '123') # converted to string (undoc'd SendGrid requirement) + # Reply-To must be moved to separate param + self.assertNotIn('Reply-To', data['headers']) + self.assertEqual(data['reply_to'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) def test_extra_headers_serialization_error(self): self.message.extra_headers = {'X-Custom': Decimal(12.5)} @@ -167,15 +160,24 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.message.send() def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) - email.send() - data = self.get_api_call_data() - self.assertNotIn('replyto', data) # don't use SendGrid's replyto (it's broken); just use headers - headers = json.loads(data['headers']) - self.assertEqual(headers['Reply-To'], 'reply@example.com, Other ') - self.assertEqual(headers['X-Other'], 'Keep') # don't lose other headers + self.message.reply_to = ['"Reply recipient" \u2019

', mimetype='text/html') self.message.send() - files = self.get_api_call_files() - self.assertEqual(files[u'files[Une pièce jointe.html]'], - (u'Une pièce jointe.html', u'

\u2019

', 'text/html')) + attachment = self.get_api_call_json()['attachments'][0] + self.assertEqual(attachment['filename'], u'Une pièce jointe.html') + self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'

\u2019

') def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -234,14 +228,15 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.message.attach_alternative(html_content, "text/html") self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['html'], html_content) + data = self.get_api_call_json() - files = self.get_api_call_files() - self.assertEqual(files, { - 'files[%s]' % image_filename: (image_filename, image_data, "image/png"), + self.assertEqual(data['attachments'][0], { + 'filename': image_filename, + 'content': b64encode(image_data).decode('ascii'), + 'type': "image/png", # type inferred from filename + 'disposition': "inline", + 'content_id': cid, }) - self.assertEqual(data['content[%s]' % image_filename], cid) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -254,50 +249,48 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.message.attach(image) self.message.send() - files = self.get_api_call_files() - self.assertEqual(files, { - 'files[%s]' % image_filename: (image_filename, image_data, "image/png"), # the named one - 'files[]': ('', image_data, "image/png"), # the unnamed one + + image_data_b64 = b64encode(image_data).decode('ascii') + data = self.get_api_call_json() + self.assertEqual(data['attachments'][0], { + 'filename': image_filename, # the named one + 'content': image_data_b64, + 'type': "image/png", + }) + self.assertEqual(data['attachments'][1], { + 'filename': '', # the unnamed one + 'content': image_data_b64, + 'type': "image/png", }) def test_multiple_html_alternatives(self): - # Multiple alternatives not allowed + # SendGrid's v3 API allows all kinds of content alternatives. + # It's unclear whether this would permit multiple text/html parts + # (the API docs warn that "If included, text/plain and text/html must be + # the first indices of the [content] array in this order"), but Anymail + # generally passes whatever the API structure supports -- deferring any + # limitations to the ESP. + self.message.body = "Text body" self.message.attach_alternative("

First html is OK

", "text/html") - self.message.attach_alternative("

But not second html

", "text/html") - with self.assertRaises(AnymailUnsupportedFeature): - self.message.send() + self.message.attach_alternative("

And maybe second html, too

", "text/html") - def test_html_alternative(self): - # Only html alternatives allowed - self.message.attach_alternative("{'not': 'allowed'}", "application/json") - with self.assertRaises(AnymailUnsupportedFeature): - self.message.send() - - def test_alternatives_fail_silently(self): - # Make sure fail_silently is respected - self.message.attach_alternative("{'not': 'allowed'}", "application/json") - sent = self.message.send(fail_silently=True) - self.assert_esp_not_called("API should not be called when send fails silently") - self.assertEqual(sent, 0) - - def test_suppress_empty_address_lists(self): - """Empty to, cc, bcc, and reply_to shouldn't generate empty headers""" self.message.send() - data = self.get_api_call_data() - self.assertNotIn('cc', data) - self.assertNotIn('ccname', data) - self.assertNotIn('bcc', data) - self.assertNotIn('bccname', data) - headers = json.loads(data['headers']) - self.assertNotIn('Reply-To', headers) + data = self.get_api_call_json() + self.assertEqual(data['content'], [ + {'type': "text/plain", 'value': "Text body"}, + {'type': "text/html", 'value': "

First html is OK

"}, + {'type': "text/html", 'value': "

And maybe second html, too

"}, + ]) - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) - self.message.to = [] - self.message.cc = ['cc@example.com'] + def test_non_html_alternative(self): + self.message.body = "Text body" + self.message.attach_alternative("{'maybe': 'allowed'}", "application/json") self.message.send() - data = self.get_api_call_data() - self.assertNotIn('to', data) - self.assertNotIn('toname', data) + data = self.get_api_call_json() + self.assertEqual(data['content'], [ + {'type': "text/plain", 'value': "Text body"}, + {'type': "application/json", 'value': "{'maybe': 'allowed'}"}, + ]) def test_api_failure(self): self.set_mock_response(status_code=400) @@ -313,17 +306,16 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): def test_api_error_includes_details(self): """AnymailAPIError should include ESP's error message""" # JSON error response: - error_response = b"""{ - "message": "error", - "errors": [ - "Helpful explanation from SendGrid", - "and more" - ] - }""" - self.set_mock_response(status_code=200, raw=error_response) - with self.assertRaisesRegex(AnymailAPIError, - r"\bHelpful explanation from SendGrid\b.*and more\b"): + error_response = b"""{"errors":[ + {"message":"Helpful explanation from SendGrid","field":"subject","help":null}, + {"message":"Another error","field":null,"help":null} + ]}""" + self.set_mock_response(status_code=400, raw=error_response) + with self.assertRaises(AnymailAPIError) as cm: self.message.send() + err = cm.exception + self.assertIn("Helpful explanation from SendGrid", str(err)) + self.assertIn("Another error", str(err)) # Non-JSON error response: self.set_mock_response(status_code=500, raw=b"Ack! Bad proxy!") @@ -340,12 +332,12 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): """Test backend support for Anymail added features""" def test_metadata(self): - # Note: SendGrid doesn't handle complex types in metadata self.message.metadata = {'user_id': "12345", 'items': 6} self.message.send() - smtpapi = self.get_smtpapi() - smtpapi['unique_args'].pop('smtp-id', None) # remove Message-ID we added as tracking workaround - self.assertEqual(smtpapi['unique_args'], {'user_id': "12345", 'items': 6}) + data = self.get_api_call_json() + data['custom_args'].pop('smtp-id', None) # remove Message-ID we added as tracking workaround + self.assertEqual(data['custom_args'], {'user_id': "12345", + 'items': "6"}) # number converted to string def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -355,76 +347,75 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): # Timezone-aware datetime converted to UTC: self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['send_at'], timegm((2016, 3, 4, 13, 6, 7))) # 05:06 UTC-8 == 13:06 UTC + data = self.get_api_call_json() + self.assertEqual(data['send_at'], timegm((2016, 3, 4, 13, 6, 7))) # 05:06 UTC-8 == 13:06 UTC # Timezone-naive datetime assumed to be Django current_timezone self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) # microseconds should get stripped self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['send_at'], timegm((2022, 10, 11, 6, 13, 14))) # 12:13 UTC+6 == 06:13 UTC + data = self.get_api_call_json() + self.assertEqual(data['send_at'], timegm((2022, 10, 11, 6, 13, 14))) # 12:13 UTC+6 == 06:13 UTC # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['send_at'], timegm((2022, 10, 21, 18, 0, 0))) # 00:00 UTC+6 == 18:00-1d UTC + data = self.get_api_call_json() + self.assertEqual(data['send_at'], timegm((2022, 10, 21, 18, 0, 0))) # 00:00 UTC+6 == 18:00-1d UTC # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['send_at'], 1651820889) + data = self.get_api_call_json() + self.assertEqual(data['send_at'], 1651820889) def test_tags(self): self.message.tags = ["receipt", "repeat-user"] self.message.send() - smtpapi = self.get_smtpapi() - self.assertCountEqual(smtpapi['category'], ["receipt", "repeat-user"]) + data = self.get_api_call_json() + self.assertCountEqual(data['categories'], ["receipt", "repeat-user"]) def test_tracking(self): # Test one way... self.message.track_clicks = False self.message.track_opens = True self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['filters']['clicktrack'], {'settings': {'enable': 0}}) - self.assertEqual(smtpapi['filters']['opentrack'], {'settings': {'enable': 1}}) + data = self.get_api_call_json() + self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': False}) + self.assertEqual(data['tracking_settings']['open_tracking'], {'enable': True}) # ...and the opposite way self.message.track_clicks = True self.message.track_opens = False self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['filters']['clicktrack'], {'settings': {'enable': 1}}) - self.assertEqual(smtpapi['filters']['opentrack'], {'settings': {'enable': 0}}) + data = self.get_api_call_json() + self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': True}) + self.assertEqual(data['tracking_settings']['open_tracking'], {'enable': False}) def test_template_id(self): - self.message.attach_alternative("HTML Body", "text/html") self.message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['filters']['templates'], { - 'settings': {'enable': 1, - 'template_id': "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f"} - }) - data = self.get_api_call_data() - self.assertEqual(data['text'], "Text Body") - self.assertEqual(data['html'], "HTML Body") + data = self.get_api_call_json() + self.assertEqual(data['template_id'], "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f") def test_template_id_with_empty_body(self): - # Text and html must be present (and non-empty-string), or the corresponding - # part will not render from the template. Make sure we fill in strings: + # v2 API required *some* text and html in message to render those template bodies, + # so the v2 backend set those to " " when necessary. + # But per v3 docs: + # "If you use a template that contains content and a subject (either text or html), + # you do not need to specify those in the respective personalizations or message + # level parameters." + # So make sure we aren't adding body content where not needed: message = mail.EmailMessage(from_email='from@example.com', to=['to@example.com']) message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" message.send() - data = self.get_api_call_data() - self.assertEqual(data['text'], " ") # single space is sufficient - self.assertEqual(data['html'], " ") + data = self.get_api_call_json() + self.assertNotIn('content', data) # neither text nor html body + self.assertNotIn('subject', data) def test_merge_data(self): self.message.from_email = 'from@example.com' - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ['alice@example.com', 'Bob ', 'celia@example.com'] + self.message.cc = ['cc@example.com'] # gets applied to *each* recipient in a merge # SendGrid template_id is not required to use merge. # You can just supply template content as the message (e.g.): self.message.body = "Hi :name. Welcome to :group at :site." @@ -433,6 +424,7 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): # as shown here, or use one of the merge_field_format options shown in the test cases below 'alice@example.com': {':name': "Alice", ':group': "Developers"}, 'bob@example.com': {':name': "Bob"}, # and leave :group undefined + # and no data for celia@example.com } self.message.merge_global_data = { ':group': "Users", @@ -440,20 +432,21 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): } self.message.send() - data = self.get_api_call_data() - smtpapi = self.get_smtpapi() - # For batch send, smtpapi['to'] gets real recipient list; - # normal 'to' is not used (but must be valid, so we substitute the from_email): - self.assertEqual(data['to'], ['from@example.com']) - self.assertEqual(data['toname'], [' ']) # empty string if no name in from_email - self.assertEqual(smtpapi['to'], ['alice@example.com', 'Bob ']) - # smtpapi['sub'] values should be in to-list order: - self.assertEqual(smtpapi['sub'], { - ':name': ["Alice", "Bob"], - ':group': ["Developers", ":group"], # missing value gets replaced with var name... - }) - self.assertEqual(smtpapi['section'], { - ':group': "Users", # ... which SG should then try to resolve from here + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [ + {'to': [{'email': 'alice@example.com'}], + 'cc': [{'email': 'cc@example.com'}], # all recipients get the cc + 'substitutions': {':name': "Alice", ':group': "Developers", + ':site': ":site"}}, # tell SG to look for global field in 'sections' + {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], + 'cc': [{'email': 'cc@example.com'}], + 'substitutions': {':name': "Bob", ':group': ":group", ':site': ":site"}}, + {'to': [{'email': 'celia@example.com'}], + 'cc': [{'email': 'cc@example.com'}], + 'substitutions': {':group': ":group", ':site': ":site"}}, # look for global fields in 'sections' + ]) + self.assertEqual(data['sections'], { + ':group': "Users", ':site': "ExampleCo", }) @@ -467,12 +460,14 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): } self.message.merge_global_data = {'site': "ExampleCo"} self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['sub'], { - ':name': ["Alice", "Bob"], - ':group': ["Developers", ":group"] # substitutes formatted field name if missing for recipient - }) - self.assertEqual(smtpapi['section'], {':site': "ExampleCo"}) + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [ + {'to': [{'email': 'alice@example.com'}], + 'substitutions': {':name': "Alice", ':group': "Developers", ':site': ":site"}}, # keys changed to :field + {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], + 'substitutions': {':name': "Bob", ':site': ":site"}} + ]) + self.assertEqual(data['sections'], {':site': "ExampleCo"}) def test_merge_field_format_esp_extra(self): # Provide merge field delimiters for an individual message @@ -484,14 +479,15 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.message.merge_global_data = {'site': "ExampleCo"} self.message.esp_extra = {'merge_field_format': '*|{}|*'} # match Mandrill/MailChimp delimiters self.message.send() - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['sub'], { - '*|name|*': ["Alice", "Bob"], - '*|group|*': ["Developers", '*|group|*'] # substitutes formatted field name if missing for recipient - }) - self.assertEqual(smtpapi['section'], {'*|site|*': "ExampleCo"}) + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [ + {'to': [{'email': 'alice@example.com'}], + 'substitutions': {'*|name|*': "Alice", '*|group|*': "Developers", '*|site|*': "*|site|*"}}, + {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], + 'substitutions': {'*|name|*': "Bob", '*|site|*': "*|site|*"}} + ]) + self.assertEqual(data['sections'], {'*|site|*': "ExampleCo"}) # Make sure our esp_extra merge_field_format doesn't get sent to SendGrid API: - data = self.get_api_call_data() self.assertNotIn('merge_field_format', data) def test_warn_if_no_merge_field_delimiters(self): @@ -502,7 +498,12 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'): self.message.send() - @override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force unique_args + def test_warn_if_no_global_merge_field_delimiters(self): + self.message.merge_global_data = {'site': "ExampleCo"} + with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'): + self.message.send() + + @override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force custom_args def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -511,40 +512,51 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): that your ESP account settings apply by default. """ self.message.send() - data = self.get_api_call_data() - self.assertNotIn('x-smtpapi', data) + data = self.get_api_call_json() + self.assertNotIn('asm', data) + self.assertNotIn('attachments', data) + self.assertNotIn('batch_id', data) + self.assertNotIn('categories', data) + self.assertNotIn('custom_args', data) + self.assertNotIn('headers', data) + self.assertNotIn('ip_pool_name', data) + self.assertNotIn('mail_settings', data) + self.assertNotIn('sections', data) + self.assertNotIn('send_at', data) + self.assertNotIn('template_id', data) + self.assertNotIn('tracking_settings', data) + + for personalization in data['personalizations']: + self.assertNotIn('custom_args', personalization) + self.assertNotIn('headers', personalization) + self.assertNotIn('send_at', personalization) + self.assertNotIn('substitutions', personalization) def test_esp_extra(self): self.message.tags = ["tag"] self.message.track_clicks = True self.message.esp_extra = { - 'x-smtpapi': { - # Most SendMail options go in the 'x-smtpapi' block... - 'asm_group_id': 1, - 'filters': { - # If you add a filter, you must supply all required settings for it. - 'subscriptiontrack': { - 'settings': { - 'enable': 1, - 'replace': '[unsubscribe_url]', - }, - }, + 'ip_pool_name': "transactional", + 'asm': { # subscription management + 'group_id': 1, + }, + 'tracking_settings': { + 'subscription_tracking': { + 'enable': True, + 'substitution_tag': '[unsubscribe_url]', }, }, - 'newthing': "some param not supported by Anymail", } self.message.send() - # Additional send params: - data = self.get_api_call_data() - self.assertEqual(data['newthing'], "some param not supported by Anymail") - # Should merge x-smtpapi, and merge filters within x-smtpapi - smtpapi = self.get_smtpapi() - self.assertEqual(smtpapi['category'], ["tag"]) - self.assertEqual(smtpapi['asm_group_id'], 1) - self.assertEqual(smtpapi['filters']['subscriptiontrack'], - {'settings': {'enable': 1, 'replace': '[unsubscribe_url]'}}) # esp_extra merged - self.assertEqual(smtpapi['filters']['clicktrack'], - {'settings': {'enable': 1}}) # Anymail message option preserved + data = self.get_api_call_json() + # merged from esp_extra: + self.assertEqual(data['ip_pool_name'], "transactional") + self.assertEqual(data['asm'], {'group_id': 1}) + self.assertEqual(data['tracking_settings']['subscription_tracking'], + {'enable': True, 'substitution_tag': "[unsubscribe_url]"}) + # make sure we didn't overwrite Anymail message options: + self.assertEqual(data['categories'], ["tag"]) + self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': True}) # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): @@ -572,29 +584,26 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.assertEqual(self.message.anymail_status.recipients, {}) self.assertIsNone(self.message.anymail_status.esp_response) - # noinspection PyUnresolvedReferences - def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") - with self.assertRaises(AnymailAPIError): - self.message.send() - self.assertIsNone(self.message.anymail_status.status) - self.assertIsNone(self.message.anymail_status.message_id) - self.assertEqual(self.message.anymail_status.recipients, {}) - self.assertEqual(self.message.anymail_status.esp_response, mock_response) - def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" self.message.metadata = {'total': Decimal('19.99')} with self.assertRaises(AnymailSerializationError) as cm: self.message.send() - print(self.get_api_call_data()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps self.assertIn("Don't know how to send this data to SendGrid", str(err)) # our added context self.assertIn("Decimal('19.99') is not JSON serializable", str(err)) # original message + @override_settings(ANYMAIL_SENDGRID_WORKAROUND_NAME_QUOTE_BUG=False) + def test_undocumented_workaround_name_quote_bug_setting(self): + mail.send_mail("Subject", "Body", '"Sender, Inc." ']) + data = self.get_api_call_json() + self.assertEqual(data["personalizations"][0]["to"][0], + {"email": "to@example.com", "name": "Recipient, Ltd."}) # no extra quotes on name + self.assertEqual(data["from"], + {"email": "from@example.com", "name": "Sender, Inc."}) + class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" @@ -602,8 +611,7 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): # SendGrid doesn't check email bounce or complaint lists at time of send -- # it always just queues the message. You'll need to listen for the "rejected" # and "failed" events to detect refused recipients. - - pass + pass # not applicable to this backend class SendGridBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendGridBackendMockAPITestCase): @@ -616,10 +624,24 @@ class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin) """Test ESP backend without required settings in place""" def test_missing_auth(self): - with self.assertRaises(ImproperlyConfigured) as cm: + with self.assertRaisesRegex(AnymailConfigurationError, r'\bSENDGRID_API_KEY\b'): mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) - errmsg = str(cm.exception) - # Make sure the exception mentions all the auth keys: - self.assertRegex(errmsg, r'\bSENDGRID_API_KEY\b') - self.assertRegex(errmsg, r'\bSENDGRID_USERNAME\b') - self.assertRegex(errmsg, r'\bSENDGRID_PASSWORD\b') + + +@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.SendGridBackend") +class SendGridBackendDisallowsV2Tests(SimpleTestCase, AnymailTestMixin): + """Using v2-API-only features should cause errors with v3 backend""" + + @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) + def test_user_pass_auth(self): + """Make sure v2-only USERNAME/PASSWORD auth raises error""" + with self.assertRaisesRegex(AnymailConfigurationError, r'\bsendgrid_v2\.EmailBackend\b'): + mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + + @override_settings(ANYMAIL={'SENDGRID_API_KEY': 'test_api_key'}) + def test_esp_extra_smtpapi(self): + """x-smtpapi in the esp_extra indicates a desire to use the v2 api""" + message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) + message.esp_extra = {'x-smtpapi': {'asm_group_id': 1}} + with self.assertRaisesRegex(AnymailConfigurationError, r'\bsendgrid_v2\.EmailBackend\b'): + message.send() diff --git a/tests/test_sendgrid_integration.py b/tests/test_sendgrid_integration.py index 43ee530..b2ebe09 100644 --- a/tests/test_sendgrid_integration.py +++ b/tests/test_sendgrid_integration.py @@ -2,7 +2,6 @@ import os import unittest from datetime import datetime, timedelta -from django.core.mail import send_mail from django.test import SimpleTestCase from django.test.utils import override_settings @@ -11,12 +10,8 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path, RUN_LIVE_TESTS -# For API_KEY auth tests: SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY') - -# For USERNAME/PASSWORD auth tests: -SENDGRID_TEST_USERNAME = os.getenv('SENDGRID_TEST_USERNAME') -SENDGRID_TEST_PASSWORD = os.getenv('SENDGRID_TEST_PASSWORD') +SENDGRID_TEST_TEMPLATE_ID = os.getenv('SENDGRID_TEST_TEMPLATE_ID') @unittest.skipUnless(RUN_LIVE_TESTS, "RUN_LIVE_TESTS disabled in this environment") @@ -24,17 +19,21 @@ SENDGRID_TEST_PASSWORD = os.getenv('SENDGRID_TEST_PASSWORD') "Set SENDGRID_TEST_API_KEY environment variable " "to run SendGrid integration tests") @override_settings(ANYMAIL_SENDGRID_API_KEY=SENDGRID_TEST_API_KEY, + ANYMAIL_SENDGRID_SEND_DEFAULTS={"esp_extra": { + "mail_settings": {"sandbox_mode": {"enable": True}}, + }}, EMAIL_BACKEND="anymail.backends.sendgrid.SendGridBackend") class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): - """SendGrid API integration tests + """SendGrid v3 API integration tests These tests run against the **live** SendGrid API, using the environment variable `SENDGRID_TEST_API_KEY` as the API key If those variables are not set, these tests won't run. - SendGrid doesn't offer a test mode -- it tries to send everything - you ask. To avoid stacking up a pile of undeliverable @example.com - emails, the tests use SendGrid's "sink domain" @sink.sendgrid.net. + The SEND_DEFAULTS above force SendGrid's v3 sandbox mode, which avoids sending mail. + (Sandbox sends also don't show in the activity feed, so disable that for live debugging.) + + The tests also use SendGrid's "sink domain" @sink.sendgrid.net for recipient addresses. https://support.sendgrid.com/hc/en-us/articles/201995663-Safely-Test-Your-Sending-Speed """ @@ -62,20 +61,21 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): def test_all_options(self): send_at = datetime.now().replace(microsecond=0) + timedelta(minutes=2) message = AnymailMessage( - subject="Anymail all-options integration test FILES", + subject="Anymail all-options integration test", body="This is the text body", - from_email="Test From ", - to=["to1@sink.sendgrid.net", "Recipient 2 "], + from_email='"Test From, with comma" ', + to=["to1@sink.sendgrid.net", '"Recipient 2, OK?" '], cc=["cc1@sink.sendgrid.net", "Copy 2 "], bcc=["bcc1@sink.sendgrid.net", "Blind Copy 2 "], - reply_to=["reply1@example.com", "Reply 2 "], - headers={"X-Anymail-Test": "value"}, + reply_to=['"Reply, with comma" '], # v3 only supports single reply-to + headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1", "tag 2"], track_clicks=True, track_opens=True, + # esp_extra={'asm': {'group_id': 1}}, # this breaks activity feed if you don't have an asm group ) message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") @@ -90,13 +90,14 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): def test_merge_data(self): message = AnymailMessage( - subject="Anymail merge_data test: %value%", - body="This body includes merge data: %value%", + subject="Anymail merge_data test: %field%", + body="This body includes merge data: %field%", from_email="Test From ", to=["to1@sink.sendgrid.net", "Recipient 2 "], + reply_to=['"Merge data in reply name: %field%" '], merge_data={ - 'to1@sink.sendgrid.net': {'value': 'one'}, - 'to2@sink.sendgrid.net': {'value': 'two'}, + 'to1@sink.sendgrid.net': {'field': 'one'}, + 'to2@sink.sendgrid.net': {'field': 'two'}, }, esp_extra={ 'merge_field_format': '%{}%', @@ -107,40 +108,30 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): self.assertEqual(recipient_status['to1@sink.sendgrid.net'].status, 'queued') self.assertEqual(recipient_status['to2@sink.sendgrid.net'].status, 'queued') + @unittest.skipUnless(SENDGRID_TEST_TEMPLATE_ID, + "Set the SENDGRID_TEST_TEMPLATE_ID environment variable " + "to a template in your SendGrid account to test stored templates") + def test_stored_template(self): + message = AnymailMessage( + from_email="Test From ", + to=["to@sink.sendgrid.net"], + template_id=SENDGRID_TEST_TEMPLATE_ID, + # The test template in the Anymail Test account has a substitution "-field-": + merge_global_data={ + 'field': 'value from merge_global_data', + }, + esp_extra={ + 'merge_field_format': '-{}-', + }, + ) + message.send() + self.assertEqual(message.anymail_status.status, {'queued'}) + @override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception - self.assertEqual(err.status_code, 400) + self.assertEqual(err.status_code, 401) # Make sure the exception message includes SendGrid's response: self.assertIn("authorization grant is invalid", str(err)) - - -@unittest.skipUnless(RUN_LIVE_TESTS, "RUN_LIVE_TESTS disabled in this environment") -@unittest.skipUnless(SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD, - "Set SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD" - "environment variables to run SendGrid integration tests") -@override_settings(ANYMAIL_SENDGRID_USERNAME=SENDGRID_TEST_USERNAME, - ANYMAIL_SENDGRID_PASSWORD=SENDGRID_TEST_PASSWORD, - EMAIL_BACKEND="anymail.backends.sendgrid.SendGridBackend") -class SendGridBackendUserPassIntegrationTests(SimpleTestCase, AnymailTestMixin): - """SendGrid username/password API integration tests - - (See notes above for the API-key tests) - """ - - def test_valid_auth(self): - sent_count = send_mail('Anymail SendGrid username/password integration test', - 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) - self.assertEqual(sent_count, 1) - - @override_settings(ANYMAIL_SENDGRID_PASSWORD="Hey, this isn't the password!") - def test_invalid_auth(self): - with self.assertRaises(AnymailAPIError) as cm: - send_mail('Anymail SendGrid username/password integration test', - 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) - err = cm.exception - self.assertEqual(err.status_code, 400) - # Make sure the exception message includes SendGrid's response: - self.assertIn("Bad username / password", str(err)) diff --git a/tests/test_sendgrid_v2_backend.py b/tests/test_sendgrid_v2_backend.py new file mode 100644 index 0000000..6f813ce --- /dev/null +++ b/tests/test_sendgrid_v2_backend.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- + +import json +from calendar import timegm +from datetime import date, datetime +from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase +from django.test.utils import override_settings +from django.utils.timezone import get_fixed_timezone, override as override_current_timezone + +from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature, AnymailWarning +from anymail.message import attach_inline_image_file + +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin + + +@override_settings(EMAIL_BACKEND='anymail.backends.sendgrid_v2.EmailBackend', + ANYMAIL={'SENDGRID_API_KEY': 'test_api_key'}) +class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b"""{ + "message": "success" + }""" + + def setUp(self): + super(SendGridBackendMockAPITestCase, self).setUp() + # Simple message useful for many tests + self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + + def get_smtpapi(self): + """Returns the x-smtpapi data passed to the mock requests call""" + data = self.get_api_call_data() + return json.loads(data["x-smtpapi"]) + + +class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): + """Test backend support for Django standard email features""" + + def test_send_mail(self): + """Test basic API for simple send""" + mail.send_mail('Subject here', 'Here is the message.', + 'from@sender.example.com', ['to@example.com'], fail_silently=False) + self.assert_esp_called('/api/mail.send.json') + http_headers = self.get_api_call_headers() + self.assertEqual(http_headers["Authorization"], "Bearer test_api_key") + + query = self.get_api_call_params(required=False) + if query: + self.assertNotIn('api_user', query) + self.assertNotIn('api_key', query) + + data = self.get_api_call_data() + self.assertEqual(data['subject'], "Subject here") + self.assertEqual(data['text'], "Here is the message.") + self.assertEqual(data['from'], "from@sender.example.com") + self.assertEqual(data['to'], ["to@example.com"]) + # make sure backend assigned a Message-ID for event tracking + email_headers = json.loads(data['headers']) + self.assertRegex(email_headers['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain + # make sure we added the Message-ID to unique_args for event notification + smtpapi = self.get_smtpapi() + self.assertEqual(email_headers['Message-ID'], smtpapi['unique_args']['smtp-id']) + + @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) + def test_user_pass_auth(self): + """Make sure alternative USERNAME/PASSWORD auth works""" + mail.send_mail('Subject here', 'Here is the message.', + 'from@sender.example.com', ['to@example.com'], fail_silently=False) + self.assert_esp_called('/api/mail.send.json') + query = self.get_api_call_params() + self.assertEqual(query['api_user'], 'sg_username') + self.assertEqual(query['api_key'], 'sg_password') + http_headers = self.get_api_call_headers(required=False) + if http_headers: + self.assertNotIn('Authorization', http_headers) + + def test_name_addr(self): + """Make sure RFC2822 name-addr format (with display-name) is allowed + + (Test both sender and recipient addresses) + """ + msg = mail.EmailMessage( + 'Subject', 'Message', 'From Name ', + ['Recipient #1 ', 'to2@example.com'], + cc=['Carbon Copy ', 'cc2@example.com'], + bcc=['Blind Copy ', 'bcc2@example.com']) + msg.send() + data = self.get_api_call_data() + self.assertEqual(data['from'], "from@example.com") + self.assertEqual(data['fromname'], "From Name") + self.assertEqual(data['to'], ['to1@example.com', 'to2@example.com']) + self.assertEqual(data['toname'], ['Recipient #1', ' ']) # note space -- SendGrid balks on '' + self.assertEqual(data['cc'], ['cc1@example.com', 'cc2@example.com']) + self.assertEqual(data['ccname'], ['Carbon Copy', ' ']) + self.assertEqual(data['bcc'], ['bcc1@example.com', 'bcc2@example.com']) + self.assertEqual(data['bccname'], ['Blind Copy', ' ']) + + def test_email_message(self): + email = mail.EmailMessage( + 'Subject', 'Body goes here', 'from@example.com', + ['to1@example.com', 'Also To '], + bcc=['bcc1@example.com', 'Also BCC '], + cc=['cc1@example.com', 'Also CC '], + headers={'Reply-To': 'another@example.com', + 'X-MyHeader': 'my value', + 'Message-ID': ''}) # should override backend msgid + email.send() + data = self.get_api_call_data() + self.assertEqual(data['subject'], "Subject") + self.assertEqual(data['text'], "Body goes here") + self.assertEqual(data['from'], "from@example.com") + self.assertEqual(data['to'], ['to1@example.com', 'to2@example.com']) + self.assertEqual(data['toname'], [' ', 'Also To']) + self.assertEqual(data['bcc'], ['bcc1@example.com', 'bcc2@example.com']) + self.assertEqual(data['bccname'], [' ', 'Also BCC']) + self.assertEqual(data['cc'], ['cc1@example.com', 'cc2@example.com']) + self.assertEqual(data['ccname'], [' ', 'Also CC']) + self.assertJSONEqual(data['headers'], { + 'Message-ID': '', + 'Reply-To': 'another@example.com', + 'X-MyHeader': 'my value', + }) + # make sure custom Message-ID also added to unique_args + self.assertJSONEqual(data['x-smtpapi'], { + 'unique_args': {'smtp-id': ''} + }) + + def test_html_message(self): + text_content = 'This is an important message.' + html_content = '

This is an important message.

' + email = mail.EmailMultiAlternatives('Subject', text_content, + 'from@example.com', ['to@example.com']) + email.attach_alternative(html_content, "text/html") + email.send() + data = self.get_api_call_data() + self.assertEqual(data['text'], text_content) + self.assertEqual(data['html'], html_content) + # Don't accidentally send the html part as an attachment: + files = self.get_api_call_files(required=False) + self.assertIsNone(files) + + def test_html_only_message(self): + html_content = '

This is an important message.

' + email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + email.content_subtype = "html" # Main content is now text/html + email.send() + data = self.get_api_call_data() + self.assertNotIn('text', data) + self.assertEqual(data['html'], html_content) + + def test_extra_headers(self): + self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} + self.message.send() + data = self.get_api_call_data() + headers = json.loads(data['headers']) + self.assertEqual(headers['X-Custom'], 'string') + self.assertEqual(headers['X-Num'], '123') # number converted to string (per SendGrid requirement) + + def test_extra_headers_serialization_error(self): + self.message.extra_headers = {'X-Custom': Decimal(12.5)} + with self.assertRaisesMessage(AnymailSerializationError, "Decimal('12.5')"): + self.message.send() + + def test_reply_to(self): + email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], + reply_to=['reply@example.com', 'Other '], + headers={'X-Other': 'Keep'}) + email.send() + data = self.get_api_call_data() + self.assertNotIn('replyto', data) # don't use SendGrid's replyto (it's broken); just use headers + headers = json.loads(data['headers']) + self.assertEqual(headers['Reply-To'], 'reply@example.com, Other ') + self.assertEqual(headers['X-Other'], 'Keep') # don't lose other headers + + def test_attachments(self): + text_content = "* Item one\n* Item two\n* Item three" + self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + + # Should guess mimetype if not provided... + png_content = b"PNG\xb4 pretend this is the contents of a png file" + self.message.attach(filename="test.png", content=png_content) + + # Should work with a MIMEBase object (also tests no filename)... + pdf_content = b"PDF\xb4 pretend this is valid pdf data" + mimeattachment = MIMEBase('application', 'pdf') + mimeattachment.set_payload(pdf_content) + self.message.attach(mimeattachment) + + self.message.send() + files = self.get_api_call_files() + self.assertEqual(files, { + 'files[test.txt]': ('test.txt', text_content, 'text/plain'), + 'files[test.png]': ('test.png', png_content, 'image/png'), # type inferred from filename + 'files[]': ('', pdf_content, 'application/pdf'), # no filename + }) + + def test_attachment_name_conflicts(self): + # It's not clear how to (or whether) supply multiple attachments with + # the same name to SendGrid's API. Anymail treats this case as unsupported. + self.message.attach('foo.txt', 'content', 'text/plain') + self.message.attach('bar.txt', 'content', 'text/plain') + self.message.attach('foo.txt', 'different content', 'text/plain') + with self.assertRaisesMessage(AnymailUnsupportedFeature, + "multiple attachments with the same filename") as cm: + self.message.send() + self.assertIn('foo.txt', str(cm.exception)) # say which filename + + def test_unnamed_attachment_conflicts(self): + # Same as previous test, but with None/empty filenames + self.message.attach(None, 'content', 'text/plain') + self.message.attach('', 'different content', 'text/plain') + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple unnamed attachments"): + self.message.send() + + def test_unicode_attachment_correctly_decoded(self): + self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.send() + files = self.get_api_call_files() + self.assertEqual(files[u'files[Une pièce jointe.html]'], + (u'Une pièce jointe.html', u'

\u2019

', 'text/html')) + + def test_embedded_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) + + cid = attach_inline_image_file(self.message, image_path) # Read from a png file + html_content = '

This has an inline image.

' % cid + self.message.attach_alternative(html_content, "text/html") + + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['html'], html_content) + + files = self.get_api_call_files() + self.assertEqual(files, { + 'files[%s]' % image_filename: (image_filename, image_data, "image/png"), + }) + self.assertEqual(data['content[%s]' % image_filename], cid) + + def test_attached_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) + + self.message.attach_file(image_path) # option 1: attach as a file + + image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + self.message.attach(image) + + self.message.send() + files = self.get_api_call_files() + self.assertEqual(files, { + 'files[%s]' % image_filename: (image_filename, image_data, "image/png"), # the named one + 'files[]': ('', image_data, "image/png"), # the unnamed one + }) + + def test_multiple_html_alternatives(self): + # Multiple alternatives not allowed + self.message.attach_alternative("

First html is OK

", "text/html") + self.message.attach_alternative("

But not second html

", "text/html") + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_html_alternative(self): + # Only html alternatives allowed + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_alternatives_fail_silently(self): + # Make sure fail_silently is respected + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + sent = self.message.send(fail_silently=True) + self.assert_esp_not_called("API should not be called when send fails silently") + self.assertEqual(sent, 0) + + def test_suppress_empty_address_lists(self): + """Empty to, cc, bcc, and reply_to shouldn't generate empty headers""" + self.message.send() + data = self.get_api_call_data() + self.assertNotIn('cc', data) + self.assertNotIn('ccname', data) + self.assertNotIn('bcc', data) + self.assertNotIn('bccname', data) + headers = json.loads(data['headers']) + self.assertNotIn('Reply-To', headers) + + # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + self.message.to = [] + self.message.cc = ['cc@example.com'] + self.message.send() + data = self.get_api_call_data() + self.assertNotIn('to', data) + self.assertNotIn('toname', data) + + def test_api_failure(self): + self.set_mock_response(status_code=400) + with self.assertRaises(AnymailAPIError): + sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + self.assertEqual(sent, 0) + + # Make sure fail_silently is respected + self.set_mock_response(status_code=400) + sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + self.assertEqual(sent, 0) + + def test_api_error_includes_details(self): + """AnymailAPIError should include ESP's error message""" + # JSON error response: + error_response = b"""{ + "message": "error", + "errors": [ + "Helpful explanation from SendGrid", + "and more" + ] + }""" + self.set_mock_response(status_code=200, raw=error_response) + with self.assertRaisesRegex(AnymailAPIError, + r"\bHelpful explanation from SendGrid\b.*and more\b"): + self.message.send() + + # Non-JSON error response: + self.set_mock_response(status_code=500, raw=b"Ack! Bad proxy!") + with self.assertRaisesMessage(AnymailAPIError, "Ack! Bad proxy!"): + self.message.send() + + # No content in the error response: + self.set_mock_response(status_code=502, raw=None) + with self.assertRaises(AnymailAPIError): + self.message.send() + + +class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): + """Test backend support for Anymail added features""" + + def test_metadata(self): + # Note: SendGrid doesn't handle complex types in metadata + self.message.metadata = {'user_id': "12345", 'items': 6} + self.message.send() + smtpapi = self.get_smtpapi() + smtpapi['unique_args'].pop('smtp-id', None) # remove Message-ID we added as tracking workaround + self.assertEqual(smtpapi['unique_args'], {'user_id': "12345", 'items': 6}) + + def test_send_at(self): + utc_plus_6 = get_fixed_timezone(6 * 60) + utc_minus_8 = get_fixed_timezone(-8 * 60) + + with override_current_timezone(utc_plus_6): + # Timezone-aware datetime converted to UTC: + self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['send_at'], timegm((2016, 3, 4, 13, 6, 7))) # 05:06 UTC-8 == 13:06 UTC + + # Timezone-naive datetime assumed to be Django current_timezone + self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) # microseconds should get stripped + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['send_at'], timegm((2022, 10, 11, 6, 13, 14))) # 12:13 UTC+6 == 06:13 UTC + + # Date-only treated as midnight in current timezone + self.message.send_at = date(2022, 10, 22) + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['send_at'], timegm((2022, 10, 21, 18, 0, 0))) # 00:00 UTC+6 == 18:00-1d UTC + + # POSIX timestamp + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['send_at'], 1651820889) + + def test_tags(self): + self.message.tags = ["receipt", "repeat-user"] + self.message.send() + smtpapi = self.get_smtpapi() + self.assertCountEqual(smtpapi['category'], ["receipt", "repeat-user"]) + + def test_tracking(self): + # Test one way... + self.message.track_clicks = False + self.message.track_opens = True + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['filters']['clicktrack'], {'settings': {'enable': 0}}) + self.assertEqual(smtpapi['filters']['opentrack'], {'settings': {'enable': 1}}) + + # ...and the opposite way + self.message.track_clicks = True + self.message.track_opens = False + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['filters']['clicktrack'], {'settings': {'enable': 1}}) + self.assertEqual(smtpapi['filters']['opentrack'], {'settings': {'enable': 0}}) + + def test_template_id(self): + self.message.attach_alternative("HTML Body", "text/html") + self.message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['filters']['templates'], { + 'settings': {'enable': 1, + 'template_id': "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f"} + }) + data = self.get_api_call_data() + self.assertEqual(data['text'], "Text Body") + self.assertEqual(data['html'], "HTML Body") + + def test_template_id_with_empty_body(self): + # Text and html must be present (and non-empty-string), or the corresponding + # part will not render from the template. Make sure we fill in strings: + message = mail.EmailMessage(from_email='from@example.com', to=['to@example.com']) + message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" + message.send() + data = self.get_api_call_data() + self.assertEqual(data['text'], " ") # single space is sufficient + self.assertEqual(data['html'], " ") + + def test_merge_data(self): + self.message.from_email = 'from@example.com' + self.message.to = ['alice@example.com', 'Bob '] + # SendGrid template_id is not required to use merge. + # You can just supply template content as the message (e.g.): + self.message.body = "Hi :name. Welcome to :group at :site." + self.message.merge_data = { + # You must either include merge field delimiters in the keys (':name' rather than just 'name') + # as shown here, or use one of the merge_field_format options shown in the test cases below + 'alice@example.com': {':name': "Alice", ':group': "Developers"}, + 'bob@example.com': {':name': "Bob"}, # and leave :group undefined + } + self.message.merge_global_data = { + ':group': "Users", + ':site': "ExampleCo", + } + self.message.send() + + data = self.get_api_call_data() + smtpapi = self.get_smtpapi() + # For batch send, smtpapi['to'] gets real recipient list; + # normal 'to' is not used (but must be valid, so we substitute the from_email): + self.assertEqual(data['to'], ['from@example.com']) + self.assertEqual(data['toname'], [' ']) # empty string if no name in from_email + self.assertEqual(smtpapi['to'], ['alice@example.com', 'Bob ']) + # smtpapi['sub'] values should be in to-list order: + self.assertEqual(smtpapi['sub'], { + ':name': ["Alice", "Bob"], + ':group': ["Developers", ":group"], # missing value gets replaced with var name... + }) + self.assertEqual(smtpapi['section'], { + ':group': "Users", # ... which SG should then try to resolve from here + ':site': "ExampleCo", + }) + + @override_settings(ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT=":{}") # :field as shown in SG examples + def test_merge_field_format_setting(self): + # Provide merge field delimiters in settings.py + self.message.to = ['alice@example.com', 'Bob '] + self.message.merge_data = { + 'alice@example.com': {'name': "Alice", 'group': "Developers"}, + 'bob@example.com': {'name': "Bob"}, # and leave group undefined + } + self.message.merge_global_data = {'site': "ExampleCo"} + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['sub'], { + ':name': ["Alice", "Bob"], + ':group': ["Developers", ":group"] # substitutes formatted field name if missing for recipient + }) + self.assertEqual(smtpapi['section'], {':site': "ExampleCo"}) + + def test_merge_field_format_esp_extra(self): + # Provide merge field delimiters for an individual message + self.message.to = ['alice@example.com', 'Bob '] + self.message.merge_data = { + 'alice@example.com': {'name': "Alice", 'group': "Developers"}, + 'bob@example.com': {'name': "Bob"}, # and leave group undefined + } + self.message.merge_global_data = {'site': "ExampleCo"} + self.message.esp_extra = {'merge_field_format': '*|{}|*'} # match Mandrill/MailChimp delimiters + self.message.send() + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['sub'], { + '*|name|*': ["Alice", "Bob"], + '*|group|*': ["Developers", '*|group|*'] # substitutes formatted field name if missing for recipient + }) + self.assertEqual(smtpapi['section'], {'*|site|*': "ExampleCo"}) + # Make sure our esp_extra merge_field_format doesn't get sent to SendGrid API: + data = self.get_api_call_data() + self.assertNotIn('merge_field_format', data) + + def test_warn_if_no_merge_field_delimiters(self): + self.message.to = ['alice@example.com'] + self.message.merge_data = { + 'alice@example.com': {'name': "Alice", 'group': "Developers"}, + } + with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'): + self.message.send() + + @override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force unique_args + def test_default_omits_options(self): + """Make sure by default we don't send any ESP-specific options. + + Options not specified by the caller should be omitted entirely from + the API call (*not* sent as False or empty). This ensures + that your ESP account settings apply by default. + """ + self.message.send() + data = self.get_api_call_data() + self.assertNotIn('x-smtpapi', data) + + def test_esp_extra(self): + self.message.tags = ["tag"] + self.message.track_clicks = True + self.message.esp_extra = { + 'x-smtpapi': { + # Most SendMail options go in the 'x-smtpapi' block... + 'asm_group_id': 1, + 'filters': { + # If you add a filter, you must supply all required settings for it. + 'subscriptiontrack': { + 'settings': { + 'enable': 1, + 'replace': '[unsubscribe_url]', + }, + }, + }, + }, + 'newthing': "some param not supported by Anymail", + } + self.message.send() + # Additional send params: + data = self.get_api_call_data() + self.assertEqual(data['newthing'], "some param not supported by Anymail") + # Should merge x-smtpapi, and merge filters within x-smtpapi + smtpapi = self.get_smtpapi() + self.assertEqual(smtpapi['category'], ["tag"]) + self.assertEqual(smtpapi['asm_group_id'], 1) + self.assertEqual(smtpapi['filters']['subscriptiontrack'], + {'settings': {'enable': 1, 'replace': '[unsubscribe_url]'}}) # esp_extra merged + self.assertEqual(smtpapi['filters']['clicktrack'], + {'settings': {'enable': 1}}) # Anymail message option preserved + + # noinspection PyUnresolvedReferences + def test_send_attaches_anymail_status(self): + """ The anymail_status should be attached to the message when it is sent """ + # the DEFAULT_RAW_RESPONSE above is the *only* success response SendGrid returns, + # so no need to override it here + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + sent = msg.send() + self.assertEqual(sent, 1) + self.assertEqual(msg.anymail_status.status, {'queued'}) + self.assertRegex(msg.anymail_status.message_id, r'\<.+@example\.com\>') # don't know exactly what it'll be + self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') + self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, + msg.anymail_status.message_id) + self.assertEqual(msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE) + + # noinspection PyUnresolvedReferences + def test_send_failed_anymail_status(self): + """ If the send fails, anymail_status should contain initial values""" + self.set_mock_response(status_code=500) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertIsNone(self.message.anymail_status.esp_response) + + # noinspection PyUnresolvedReferences + def test_send_unparsable_response(self): + """If the send succeeds, but a non-JSON API response, should raise an API exception""" + mock_response = self.set_mock_response(status_code=200, + raw=b"yikes, this isn't a real response") + with self.assertRaises(AnymailAPIError): + self.message.send() + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertEqual(self.message.anymail_status.esp_response, mock_response) + + def test_json_serialization_errors(self): + """Try to provide more information about non-json-serializable data""" + self.message.metadata = {'total': Decimal('19.99')} + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + print(self.get_api_call_data()) + err = cm.exception + self.assertIsInstance(err, TypeError) # compatibility with json.dumps + self.assertIn("Don't know how to send this data to SendGrid", str(err)) # our added context + self.assertIn("Decimal('19.99') is not JSON serializable", str(err)) # original message + + +class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): + """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + + # SendGrid doesn't check email bounce or complaint lists at time of send -- + # it always just queues the message. You'll need to listen for the "rejected" + # and "failed" events to detect refused recipients. + + pass + + +class SendGridBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendGridBackendMockAPITestCase): + """Requests session sharing tests""" + pass # tests are defined in the mixin + + +@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid_v2.EmailBackend") +class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): + """Test ESP backend without required settings in place""" + + def test_missing_auth(self): + with self.assertRaises(ImproperlyConfigured) as cm: + mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + errmsg = str(cm.exception) + # Make sure the exception mentions all the auth keys: + self.assertRegex(errmsg, r'\bSENDGRID_API_KEY\b') + self.assertRegex(errmsg, r'\bSENDGRID_USERNAME\b') + self.assertRegex(errmsg, r'\bSENDGRID_PASSWORD\b') diff --git a/tests/test_sendgrid_v2_integration.py b/tests/test_sendgrid_v2_integration.py new file mode 100644 index 0000000..1dfb254 --- /dev/null +++ b/tests/test_sendgrid_v2_integration.py @@ -0,0 +1,146 @@ +import os +import unittest +from datetime import datetime, timedelta + +from django.core.mail import send_mail +from django.test import SimpleTestCase +from django.test.utils import override_settings + +from anymail.exceptions import AnymailAPIError +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin, sample_image_path, RUN_LIVE_TESTS + +# For API_KEY auth tests: +SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY') + +# For USERNAME/PASSWORD auth tests: +SENDGRID_TEST_USERNAME = os.getenv('SENDGRID_TEST_USERNAME') +SENDGRID_TEST_PASSWORD = os.getenv('SENDGRID_TEST_PASSWORD') + + +@unittest.skipUnless(RUN_LIVE_TESTS, "RUN_LIVE_TESTS disabled in this environment") +@unittest.skipUnless(SENDGRID_TEST_API_KEY, + "Set SENDGRID_TEST_API_KEY environment variable " + "to run SendGrid integration tests") +@override_settings(ANYMAIL_SENDGRID_API_KEY=SENDGRID_TEST_API_KEY, + EMAIL_BACKEND="anymail.backends.sendgrid_v2.EmailBackend") +class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): + """SendGrid v2 API integration tests + + These tests run against the **live** SendGrid API, using the + environment variable `SENDGRID_TEST_API_KEY` as the API key + If those variables are not set, these tests won't run. + + SendGrid v2 doesn't offer a test mode -- it tries to send everything + you ask. To avoid stacking up a pile of undeliverable @example.com + emails, the tests use SendGrid's "sink domain" @sink.sendgrid.net. + https://support.sendgrid.com/hc/en-us/articles/201995663-Safely-Test-Your-Sending-Speed + + """ + + def setUp(self): + super(SendGridBackendIntegrationTests, self).setUp() + self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content', + 'from@example.com', ['to@sink.sendgrid.net']) + self.message.attach_alternative('

HTML content

', "text/html") + + def test_simple_send(self): + # Example of getting the SendGrid send status and message id from the message + sent_count = self.message.send() + self.assertEqual(sent_count, 1) + + anymail_status = self.message.anymail_status + sent_status = anymail_status.recipients['to@sink.sendgrid.net'].status + message_id = anymail_status.recipients['to@sink.sendgrid.net'].message_id + + self.assertEqual(sent_status, 'queued') # SendGrid always queues + self.assertRegex(message_id, r'\<.+@example\.com\>') # should use from_email's domain + self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + self.assertEqual(anymail_status.message_id, message_id) + + def test_all_options(self): + send_at = datetime.now().replace(microsecond=0) + timedelta(minutes=2) + message = AnymailMessage( + subject="Anymail all-options integration test FILES", + body="This is the text body", + from_email="Test From ", + to=["to1@sink.sendgrid.net", "Recipient 2 "], + cc=["cc1@sink.sendgrid.net", "Copy 2 "], + bcc=["bcc1@sink.sendgrid.net", "Blind Copy 2 "], + reply_to=["reply1@example.com", "Reply 2 "], + headers={"X-Anymail-Test": "value"}, + + metadata={"meta1": "simple string", "meta2": 2}, + send_at=send_at, + tags=["tag 1", "tag 2"], + track_clicks=True, + track_opens=True, + ) + message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") + cid = message.attach_inline_image_file(sample_image_path()) + message.attach_alternative( + "

HTML: with link" + "and image: " % cid, + "text/html") + + message.send() + self.assertEqual(message.anymail_status.status, {'queued'}) # SendGrid always queues + + def test_merge_data(self): + message = AnymailMessage( + subject="Anymail merge_data test: %value%", + body="This body includes merge data: %value%", + from_email="Test From ", + to=["to1@sink.sendgrid.net", "Recipient 2 "], + merge_data={ + 'to1@sink.sendgrid.net': {'value': 'one'}, + 'to2@sink.sendgrid.net': {'value': 'two'}, + }, + esp_extra={ + 'merge_field_format': '%{}%', + }, + ) + message.send() + recipient_status = message.anymail_status.recipients + self.assertEqual(recipient_status['to1@sink.sendgrid.net'].status, 'queued') + self.assertEqual(recipient_status['to2@sink.sendgrid.net'].status, 'queued') + + @override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!") + def test_invalid_api_key(self): + with self.assertRaises(AnymailAPIError) as cm: + self.message.send() + err = cm.exception + self.assertEqual(err.status_code, 400) + # Make sure the exception message includes SendGrid's response: + self.assertIn("authorization grant is invalid", str(err)) + + +@unittest.skipUnless(RUN_LIVE_TESTS, "RUN_LIVE_TESTS disabled in this environment") +@unittest.skipUnless(SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD, + "Set SENDGRID_TEST_USERNAME and SENDGRID_TEST_PASSWORD" + "environment variables to run SendGrid integration tests") +@override_settings(ANYMAIL_SENDGRID_USERNAME=SENDGRID_TEST_USERNAME, + ANYMAIL_SENDGRID_PASSWORD=SENDGRID_TEST_PASSWORD, + EMAIL_BACKEND="anymail.backends.sendgrid_v2.EmailBackend") +class SendGridBackendUserPassIntegrationTests(SimpleTestCase, AnymailTestMixin): + """SendGrid username/password API integration tests + + (See notes above for the API-key tests) + """ + + def test_valid_auth(self): + sent_count = send_mail('Anymail SendGrid username/password integration test', + 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) + self.assertEqual(sent_count, 1) + + @override_settings(ANYMAIL_SENDGRID_PASSWORD="Hey, this isn't the password!") + def test_invalid_auth(self): + with self.assertRaises(AnymailAPIError) as cm: + send_mail('Anymail SendGrid username/password integration test', + 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) + err = cm.exception + self.assertEqual(err.status_code, 400) + # Make sure the exception message includes SendGrid's response: + self.assertIn("Bad username / password", str(err)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3e23189..7a9ddf4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,7 @@ from django.test import SimpleTestCase from django.utils.translation import ugettext_lazy, string_concat from anymail.exceptions import AnymailInvalidAddress -from anymail.utils import ParsedEmail, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list +from anymail.utils import ParsedEmail, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, update_deep class ParsedEmailTests(SimpleTestCase): @@ -116,3 +116,29 @@ class LazyCoercionTests(SimpleTestCase): result = force_non_lazy_list([0, ugettext_lazy(u"b"), u"c"]) self.assertEqual(result, [0, u"b", u"c"]) # coerced to list self.assertIsInstance(result[1], six.text_type) + + +class UpdateDeepTests(SimpleTestCase): + """Test utils.update_deep""" + + def test_updates_recursively(self): + first = {'a': {'a1': 1, 'aa': {}}, 'b': "B"} + second = {'a': {'a2': 2, 'aa': {'aa1': 11}}} + result = update_deep(first, second) + self.assertEqual(first, {'a': {'a1': 1, 'a2': 2, 'aa': {'aa1': 11}}, 'b': "B"}) + self.assertIsNone(result) # modifies first in place; doesn't return it (same as dict.update()) + + def test_overwrites_sequences(self): + """Only mappings are handled recursively; sequences are considered atomic""" + first = {'a': [1, 2]} + second = {'a': [3]} + update_deep(first, second) + self.assertEqual(first, {'a': [3]}) + + def test_handles_non_dict_mappings(self): + """Mapping types in general are supported""" + from collections import OrderedDict, defaultdict + first = OrderedDict(a=OrderedDict(a1=1), c={'c1': 1}) + second = defaultdict(None, a=dict(a2=2)) + update_deep(first, second) + self.assertEqual(first, {'a': {'a1': 1, 'a2': 2}, 'c': {'c1': 1}})