diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 4005d1ac..2f161ccb 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -40,6 +40,7 @@ jobs: # combination, to avoid rapidly consuming the testing accounts' entire send allotments. config: - { tox: django41-py310-amazon_ses, python: "3.10" } + - { tox: django41-py310-brevo, python: "3.10" } - { tox: django41-py310-mailersend, python: "3.10" } - { tox: django41-py310-mailgun, python: "3.10" } - { tox: django41-py310-mailjet, python: "3.10" } @@ -48,7 +49,6 @@ jobs: - { tox: django41-py310-postmark, python: "3.10" } - { tox: django41-py310-resend, python: "3.10" } - { tox: django41-py310-sendgrid, python: "3.10" } - - { tox: django41-py310-sendinblue, python: "3.10" } - { tox: django41-py310-sparkpost, python: "3.10" } - { tox: django41-py310-unisender_go, python: "3.10" } @@ -77,6 +77,8 @@ jobs: ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }} ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }} ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }} + ANYMAIL_TEST_BREVO_API_KEY: ${{ secrets.ANYMAIL_TEST_BREVO_API_KEY }} + ANYMAIL_TEST_BREVO_DOMAIN: ${{ vars.ANYMAIL_TEST_BREVO_DOMAIN }} ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }} ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }} ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }} @@ -94,8 +96,6 @@ jobs: ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }} ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }} ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }} - ANYMAIL_TEST_SENDINBLUE_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_API_KEY }} - ANYMAIL_TEST_SENDINBLUE_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_DOMAIN }} ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 050786ec..f6a5c493 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -30,6 +30,16 @@ vNext *unreleased changes* +Deprecations +~~~~~~~~~~~~ + +* **SendinBlue:** Rename "SendinBlue" to "Brevo" throughout Anymail's code. + This affects the email backend name, settings names, and webhook URLs. + The old names will continue to work for now, but are deprecated. See + `Updating code from SendinBlue to Brevo `__ + for details. + + Features ~~~~~~~~ diff --git a/anymail/backends/brevo.py b/anymail/backends/brevo.py new file mode 100644 index 00000000..55124236 --- /dev/null +++ b/anymail/backends/brevo.py @@ -0,0 +1,220 @@ +from requests.structures import CaseInsensitiveDict + +from ..exceptions import AnymailRequestsAPIError +from ..message import AnymailRecipientStatus +from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class EmailBackend(AnymailRequestsBackend): + """ + Brevo v3 API Email Backend + """ + + esp_name = "Brevo" + + def __init__(self, **kwargs): + """Init options from Django settings""" + esp_name = self.esp_name + self.api_key = get_anymail_setting( + "api_key", + esp_name=esp_name, + kwargs=kwargs, + allow_bare=True, + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.brevo.com/v3/", + ) + if not api_url.endswith("/"): + api_url += "/" + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return BrevoPayload(message, defaults, self) + + def parse_recipient_status(self, response, payload, message): + # Brevo doesn't give any detail on a success, other than messageId + # https://developers.brevo.com/reference/sendtransacemail + message_id = None + message_ids = [] + + if response.content != b"": + parsed_response = self.deserialize_json_response(response, payload, message) + try: + message_id = parsed_response["messageId"] + except (KeyError, TypeError): + try: + # batch send + message_ids = parsed_response["messageIds"] + except (KeyError, TypeError) as err: + raise AnymailRequestsAPIError( + "Invalid Brevo API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err + + status = AnymailRecipientStatus(message_id=message_id, status="queued") + recipient_status = { + recipient.addr_spec: status for recipient in payload.all_recipients + } + if message_ids: + for to, message_id in zip(payload.to_recipients, message_ids): + recipient_status[to.addr_spec] = AnymailRecipientStatus( + message_id=message_id, status="queued" + ) + return recipient_status + + +class BrevoPayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + self.all_recipients = [] # used for backend.parse_recipient_status + self.to_recipients = [] # used for backend.parse_recipient_status + + http_headers = kwargs.pop("headers", {}) + http_headers["api-key"] = backend.api_key + http_headers["Content-Type"] = "application/json" + + super().__init__( + message, defaults, backend, headers=http_headers, *args, **kwargs + ) + + def get_api_endpoint(self): + return "smtp/email" + + def init_payload(self): + self.data = {"headers": CaseInsensitiveDict()} # becomes json + self.merge_data = {} + self.metadata = {} + self.merge_metadata = {} + + def serialize_data(self): + """Performs any necessary serialization on self.data, and returns the result.""" + if self.is_batch(): + # Burst data["to"] into data["messageVersions"] + to_list = self.data.pop("to", []) + self.data["messageVersions"] = [ + {"to": [to], "params": self.merge_data.get(to["email"])} + for to in to_list + ] + if self.merge_metadata: + # Merge global metadata with any per-recipient metadata. + # (Top-level X-Mailin-custom header is already set to global metadata, + # and will apply for recipients without a "headers" override.) + for version in self.data["messageVersions"]: + to_email = version["to"][0]["email"] + if to_email in self.merge_metadata: + recipient_metadata = self.metadata.copy() + recipient_metadata.update(self.merge_metadata[to_email]) + version["headers"] = { + "X-Mailin-custom": self.serialize_json(recipient_metadata) + } + + if not self.data["headers"]: + del self.data["headers"] # don't send empty headers + return self.serialize_json(self.data) + + # + # Payload construction + # + + @staticmethod + def email_object(email): + """Converts EmailAddress to Brevo API array""" + email_object = dict() + email_object["email"] = email.addr_spec + if email.display_name: + email_object["name"] = email.display_name + return email_object + + def set_from_email(self, email): + self.data["sender"] = self.email_object(email) + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + self.data[recipient_type] = [self.email_object(email) for email in emails] + self.all_recipients += emails # used for backend.parse_recipient_status + if recipient_type == "to": + self.to_recipients = emails # used for backend.parse_recipient_status + + def set_subject(self, subject): + if subject != "": # see note in set_text_body about template rendering + self.data["subject"] = subject + + def set_reply_to(self, emails): + # Brevo 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["replyTo"] = self.email_object(emails[0]) + + def set_extra_headers(self, headers): + # Brevo requires header values to be strings (not integers) as of 11/2022. + # Stringify ints and floats; anything else is the caller's responsibility. + self.data["headers"].update( + { + k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v + for k, v in headers.items() + } + ) + + def set_tags(self, tags): + if len(tags) > 0: + self.data["tags"] = tags + + def set_template_id(self, template_id): + self.data["templateId"] = template_id + + def set_text_body(self, body): + if body: + self.data["textContent"] = body + + def set_html_body(self, body): + if body: + if "htmlContent" in self.data: + self.unsupported_feature("multiple html parts") + + self.data["htmlContent"] = body + + def add_attachment(self, attachment): + """Converts attachments to Brevo API {name, base64} array""" + att = { + "name": attachment.name or "", + "content": attachment.b64content, + } + + if attachment.inline: + self.unsupported_feature("inline attachments") + + self.data.setdefault("attachment", []).append(att) + + def set_esp_extra(self, extra): + self.data.update(extra) + + def set_merge_data(self, merge_data): + # Late bound in serialize_data: + self.merge_data = merge_data + + def set_merge_global_data(self, merge_global_data): + self.data["params"] = merge_global_data + + def set_metadata(self, metadata): + # Brevo expects a single string payload + self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata) + self.metadata = metadata # needed in serialize_data for batch send + + def set_merge_metadata(self, merge_metadata): + # Late-bound in serialize_data: + self.merge_metadata = merge_metadata + + def set_send_at(self, send_at): + try: + start_time_iso = send_at.isoformat(timespec="milliseconds") + except (AttributeError, TypeError): + start_time_iso = send_at # assume user already formatted + self.data["scheduledAt"] = start_time_iso diff --git a/anymail/backends/sendinblue.py b/anymail/backends/sendinblue.py index e469c915..15bd3d90 100644 --- a/anymail/backends/sendinblue.py +++ b/anymail/backends/sendinblue.py @@ -1,220 +1,20 @@ -from requests.structures import CaseInsensitiveDict +import warnings -from ..exceptions import AnymailRequestsAPIError -from ..message import AnymailRecipientStatus -from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting -from .base_requests import AnymailRequestsBackend, RequestsPayload +from ..exceptions import AnymailDeprecationWarning +from .brevo import EmailBackend as BrevoEmailBackend -class EmailBackend(AnymailRequestsBackend): +class EmailBackend(BrevoEmailBackend): """ - SendinBlue v3 API Email Backend + Deprecated compatibility backend for old Brevo name "SendinBlue". """ esp_name = "SendinBlue" def __init__(self, **kwargs): - """Init options from Django settings""" - esp_name = self.esp_name - self.api_key = get_anymail_setting( - "api_key", - esp_name=esp_name, - kwargs=kwargs, - allow_bare=True, + warnings.warn( + "`anymail.backends.sendinblue.EmailBackend` has been renamed" + " `anymail.backends.brevo.EmailBackend`.", + AnymailDeprecationWarning, ) - api_url = get_anymail_setting( - "api_url", - esp_name=esp_name, - kwargs=kwargs, - default="https://api.brevo.com/v3/", - ) - if not api_url.endswith("/"): - api_url += "/" - super().__init__(api_url, **kwargs) - - def build_message_payload(self, message, defaults): - return SendinBluePayload(message, defaults, self) - - def parse_recipient_status(self, response, payload, message): - # SendinBlue doesn't give any detail on a success - # https://developers.sendinblue.com/docs/responses - message_id = None - message_ids = [] - - if response.content != b"": - parsed_response = self.deserialize_json_response(response, payload, message) - try: - message_id = parsed_response["messageId"] - except (KeyError, TypeError): - try: - # batch send - message_ids = parsed_response["messageIds"] - except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError( - "Invalid SendinBlue API response format", - email_message=message, - payload=payload, - response=response, - backend=self, - ) from err - - status = AnymailRecipientStatus(message_id=message_id, status="queued") - recipient_status = { - recipient.addr_spec: status for recipient in payload.all_recipients - } - if message_ids: - for to, message_id in zip(payload.to_recipients, message_ids): - recipient_status[to.addr_spec] = AnymailRecipientStatus( - message_id=message_id, status="queued" - ) - return recipient_status - - -class SendinBluePayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): - self.all_recipients = [] # used for backend.parse_recipient_status - self.to_recipients = [] # used for backend.parse_recipient_status - - http_headers = kwargs.pop("headers", {}) - http_headers["api-key"] = backend.api_key - http_headers["Content-Type"] = "application/json" - - super().__init__( - message, defaults, backend, headers=http_headers, *args, **kwargs - ) - - def get_api_endpoint(self): - return "smtp/email" - - def init_payload(self): - self.data = {"headers": CaseInsensitiveDict()} # becomes json - self.merge_data = {} - self.metadata = {} - self.merge_metadata = {} - - def serialize_data(self): - """Performs any necessary serialization on self.data, and returns the result.""" - if self.is_batch(): - # Burst data["to"] into data["messageVersions"] - to_list = self.data.pop("to", []) - self.data["messageVersions"] = [ - {"to": [to], "params": self.merge_data.get(to["email"])} - for to in to_list - ] - if self.merge_metadata: - # Merge global metadata with any per-recipient metadata. - # (Top-level X-Mailin-custom header is already set to global metadata, - # and will apply for recipients without a "headers" override.) - for version in self.data["messageVersions"]: - to_email = version["to"][0]["email"] - if to_email in self.merge_metadata: - recipient_metadata = self.metadata.copy() - recipient_metadata.update(self.merge_metadata[to_email]) - version["headers"] = { - "X-Mailin-custom": self.serialize_json(recipient_metadata) - } - - if not self.data["headers"]: - del self.data["headers"] # don't send empty headers - return self.serialize_json(self.data) - - # - # Payload construction - # - - @staticmethod - def email_object(email): - """Converts EmailAddress to SendinBlue API array""" - email_object = dict() - email_object["email"] = email.addr_spec - if email.display_name: - email_object["name"] = email.display_name - return email_object - - def set_from_email(self, email): - self.data["sender"] = self.email_object(email) - - def set_recipients(self, recipient_type, emails): - assert recipient_type in ["to", "cc", "bcc"] - if emails: - self.data[recipient_type] = [self.email_object(email) for email in emails] - self.all_recipients += emails # used for backend.parse_recipient_status - if recipient_type == "to": - self.to_recipients = emails # used for backend.parse_recipient_status - - def set_subject(self, subject): - if subject != "": # see note in set_text_body about template rendering - self.data["subject"] = subject - - def set_reply_to(self, emails): - # SendinBlue 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["replyTo"] = self.email_object(emails[0]) - - def set_extra_headers(self, headers): - # SendinBlue requires header values to be strings (not integers) as of 11/2022. - # Stringify ints and floats; anything else is the caller's responsibility. - self.data["headers"].update( - { - k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v - for k, v in headers.items() - } - ) - - def set_tags(self, tags): - if len(tags) > 0: - self.data["tags"] = tags - - def set_template_id(self, template_id): - self.data["templateId"] = template_id - - def set_text_body(self, body): - if body: - self.data["textContent"] = body - - def set_html_body(self, body): - if body: - if "htmlContent" in self.data: - self.unsupported_feature("multiple html parts") - - self.data["htmlContent"] = body - - def add_attachment(self, attachment): - """Converts attachments to SendinBlue API {name, base64} array""" - att = { - "name": attachment.name or "", - "content": attachment.b64content, - } - - if attachment.inline: - self.unsupported_feature("inline attachments") - - self.data.setdefault("attachment", []).append(att) - - def set_esp_extra(self, extra): - self.data.update(extra) - - def set_merge_data(self, merge_data): - # Late bound in serialize_data: - self.merge_data = merge_data - - def set_merge_global_data(self, merge_global_data): - self.data["params"] = merge_global_data - - def set_metadata(self, metadata): - # SendinBlue expects a single string payload - self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata) - self.metadata = metadata # needed in serialize_data for batch send - - def set_merge_metadata(self, merge_metadata): - # Late-bound in serialize_data: - self.merge_metadata = merge_metadata - - def set_send_at(self, send_at): - try: - start_time_iso = send_at.isoformat(timespec="milliseconds") - except (AttributeError, TypeError): - start_time_iso = send_at # assume user already formatted - self.data["scheduledAt"] = start_time_iso + super().__init__(**kwargs) diff --git a/anymail/urls.py b/anymail/urls.py index 28647b4e..050d9b76 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -4,6 +4,7 @@ AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView, ) +from .webhooks.brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView from .webhooks.mailersend import ( MailerSendInboundWebhookView, MailerSendTrackingWebhookView, @@ -32,6 +33,11 @@ AmazonSESInboundWebhookView.as_view(), name="amazon_ses_inbound_webhook", ), + path( + "brevo/inbound/", + BrevoInboundWebhookView.as_view(), + name="brevo_inbound_webhook", + ), path( "mailersend/inbound/", MailerSendInboundWebhookView.as_view(), @@ -67,6 +73,7 @@ name="sendgrid_inbound_webhook", ), path( + # Compatibility for old SendinBlue esp_name; use Brevo in new code "sendinblue/inbound/", SendinBlueInboundWebhookView.as_view(), name="sendinblue_inbound_webhook", @@ -81,6 +88,11 @@ AmazonSESTrackingWebhookView.as_view(), name="amazon_ses_tracking_webhook", ), + path( + "brevo/tracking/", + BrevoTrackingWebhookView.as_view(), + name="brevo_tracking_webhook", + ), path( "mailersend/tracking/", MailerSendTrackingWebhookView.as_view(), @@ -117,6 +129,7 @@ name="sendgrid_tracking_webhook", ), path( + # Compatibility for old SendinBlue esp_name; use Brevo in new code "sendinblue/tracking/", SendinBlueTrackingWebhookView.as_view(), name="sendinblue_tracking_webhook", diff --git a/anymail/webhooks/brevo.py b/anymail/webhooks/brevo.py new file mode 100644 index 00000000..38e91b2f --- /dev/null +++ b/anymail/webhooks/brevo.py @@ -0,0 +1,220 @@ +import json +from datetime import datetime, timezone +from email.utils import unquote +from urllib.parse import quote, urljoin + +import requests + +from ..exceptions import AnymailConfigurationError +from ..inbound import AnymailInboundMessage +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from ..utils import get_anymail_setting +from .base import AnymailBaseWebhookView + + +class BrevoBaseWebhookView(AnymailBaseWebhookView): + esp_name = "Brevo" + + +class BrevoTrackingWebhookView(BrevoBaseWebhookView): + """Handler for Brevo delivery and engagement tracking webhooks""" + + # https://developers.brevo.com/docs/transactional-webhooks + + signal = tracking + + def parse_events(self, request): + esp_event = json.loads(request.body.decode("utf-8")) + if "items" in esp_event: + # This is an inbound webhook post + raise AnymailConfigurationError( + f"You seem to have set Brevo's *inbound* webhook URL " + f"to Anymail's {self.esp_name} *tracking* webhook URL." + ) + return [self.esp_to_anymail_event(esp_event)] + + event_types = { + # Map Brevo event type: Anymail normalized (event type, reject reason) + # received even if message won't be sent (e.g., before "blocked"): + "request": (EventType.QUEUED, None), + "delivered": (EventType.DELIVERED, None), + "hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), + "soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), + "blocked": (EventType.REJECTED, RejectReason.BLOCKED), + "spam": (EventType.COMPLAINED, RejectReason.SPAM), + "invalid_email": (EventType.BOUNCED, RejectReason.INVALID), + "deferred": (EventType.DEFERRED, None), + "opened": (EventType.OPENED, None), # see also unique_opened below + "click": (EventType.CLICKED, None), + "unsubscribe": (EventType.UNSUBSCRIBED, None), + # shouldn't occur for transactional messages: + "list_addition": (EventType.SUBSCRIBED, None), + "unique_opened": (EventType.OPENED, None), # first open; see also opened above + } + + def esp_to_anymail_event(self, esp_event): + esp_type = esp_event.get("event") + event_type, reject_reason = self.event_types.get( + esp_type, (EventType.UNKNOWN, None) + ) + recipient = esp_event.get("email") + + try: + # Brevo supplies "ts", "ts_event" and "date" fields, which seem to be + # based on the timezone set in the account preferences (and possibly with + # inconsistent DST adjustment). "ts_epoch" is the only field that seems to + # be consistently UTC; it's in milliseconds + timestamp = datetime.fromtimestamp( + esp_event["ts_epoch"] / 1000.0, tz=timezone.utc + ) + except (KeyError, ValueError): + timestamp = None + + tags = [] + try: + # If `tags` param set on send, webhook payload includes 'tags' array field. + tags = esp_event["tags"] + except KeyError: + try: + # If `X-Mailin-Tag` header set on send, webhook payload includes single + # 'tag' string. (If header not set, webhook 'tag' will be the template + # name for template sends.) + tags = [esp_event["tag"]] + except KeyError: + pass + + try: + metadata = json.loads(esp_event["X-Mailin-custom"]) + except (KeyError, TypeError): + metadata = {} + + return AnymailTrackingEvent( + description=None, + esp_event=esp_event, + # Brevo doesn't provide a unique event id: + event_id=None, + event_type=event_type, + message_id=esp_event.get("message-id"), + metadata=metadata, + mta_response=esp_event.get("reason"), + recipient=recipient, + reject_reason=reject_reason, + tags=tags, + timestamp=timestamp, + user_agent=None, + click_url=esp_event.get("link"), + ) + + +class BrevoInboundWebhookView(BrevoBaseWebhookView): + """Handler for Brevo inbound email webhooks""" + + # https://developers.brevo.com/docs/inbound-parse-webhooks#parsed-email-payload + + signal = inbound + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # API is required to fetch inbound attachment content: + self.api_key = get_anymail_setting( + "api_key", + esp_name=self.esp_name, + kwargs=kwargs, + allow_bare=True, + ) + self.api_url = get_anymail_setting( + "api_url", + esp_name=self.esp_name, + kwargs=kwargs, + default="https://api.brevo.com/v3/", + ) + if not self.api_url.endswith("/"): + self.api_url += "/" + + def parse_events(self, request): + payload = json.loads(request.body.decode("utf-8")) + try: + esp_events = payload["items"] + except KeyError: + # This is not an inbound webhook post + raise AnymailConfigurationError( + f"You seem to have set Brevo's *tracking* webhook URL " + f"to Anymail's {self.esp_name} *inbound* webhook URL." + ) + else: + return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] + + def esp_to_anymail_event(self, esp_event): + # Inbound event's "Uuid" is documented as + # "A list of recipients UUID (can be used with the Public API)". + # In practice, it seems to be a single-item list (even when sending + # to multiple inbound recipients at once) that uniquely identifies this + # inbound event. (And works as a param for the /inbound/events/{uuid} API + # that will "Fetch all events history for one particular received email.") + try: + event_id = esp_event["Uuid"][0] + except (KeyError, IndexError): + event_id = None + + attachments = [ + self._fetch_attachment(attachment) + for attachment in esp_event.get("Attachments", []) + ] + headers = [ + (name, value) + for name, values in esp_event.get("Headers", {}).items() + # values is string if single header instance, list of string if multiple + for value in ([values] if isinstance(values, str) else values) + ] + + # (esp_event From, To, Cc, ReplyTo, Subject, Date, etc. are also in Headers) + message = AnymailInboundMessage.construct( + headers=headers, + text=esp_event.get("RawTextBody", ""), + html=esp_event.get("RawHtmlBody", ""), + attachments=attachments, + ) + + if message["Return-Path"]: + message.envelope_sender = unquote(message["Return-Path"]) + if message["Delivered-To"]: + message.envelope_recipient = unquote(message["Delivered-To"]) + message.stripped_text = esp_event.get("ExtractedMarkdownMessage") + + # Documented as "Spam.Score" object, but both example payload + # and actual received payload use single "SpamScore" field: + message.spam_score = esp_event.get("SpamScore") + + return AnymailInboundEvent( + event_type=EventType.INBOUND, + timestamp=None, # Brevo doesn't provide inbound event timestamp + event_id=event_id, + esp_event=esp_event, + message=message, + ) + + def _fetch_attachment(self, attachment): + # Download attachment content from Brevo API. + # FUTURE: somehow defer download until attachment is accessed? + token = attachment["DownloadToken"] + url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}") + response = requests.get(url, headers={"api-key": self.api_key}) + response.raise_for_status() # or maybe just log and continue? + + content = response.content + # Prefer response Content-Type header to attachment ContentType field, + # as the header will include charset but the ContentType field won't. + content_type = response.headers.get("Content-Type") or attachment["ContentType"] + return AnymailInboundMessage.construct_attachment( + content_type=content_type, + content=content, + filename=attachment.get("Name"), + content_id=attachment.get("ContentID"), + ) diff --git a/anymail/webhooks/sendinblue.py b/anymail/webhooks/sendinblue.py index 6cdd4350..61d180c8 100644 --- a/anymail/webhooks/sendinblue.py +++ b/anymail/webhooks/sendinblue.py @@ -1,218 +1,38 @@ -import json -from datetime import datetime, timezone -from email.utils import unquote -from urllib.parse import quote, urljoin +import warnings -import requests +from ..exceptions import AnymailDeprecationWarning +from .brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView -from ..exceptions import AnymailConfigurationError -from ..inbound import AnymailInboundMessage -from ..signals import ( - AnymailInboundEvent, - AnymailTrackingEvent, - EventType, - RejectReason, - inbound, - tracking, -) -from ..utils import get_anymail_setting -from .base import AnymailBaseWebhookView +class SendinBlueTrackingWebhookView(BrevoTrackingWebhookView): + """ + Deprecated compatibility tracking webhook for old Brevo name "SendinBlue". + """ -class SendinBlueBaseWebhookView(AnymailBaseWebhookView): esp_name = "SendinBlue" - -class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView): - """Handler for SendinBlue delivery and engagement tracking webhooks""" - - signal = tracking - - def parse_events(self, request): - esp_event = json.loads(request.body.decode("utf-8")) - if "items" in esp_event: - # This is an inbound webhook post - raise AnymailConfigurationError( - "You seem to have set SendinBlue's *inbound* webhook URL " - "to Anymail's SendinBlue *tracking* webhook URL." - ) - return [self.esp_to_anymail_event(esp_event)] - - # SendinBlue's webhook payload data doesn't seem to be documented anywhere. - # There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3. - event_types = { - # Map SendinBlue event type: Anymail normalized (event type, reject reason) - # received even if message won't be sent (e.g., before "blocked"): - "request": (EventType.QUEUED, None), - "delivered": (EventType.DELIVERED, None), - "hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), - "soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), - "blocked": (EventType.REJECTED, RejectReason.BLOCKED), - "spam": (EventType.COMPLAINED, RejectReason.SPAM), - "invalid_email": (EventType.BOUNCED, RejectReason.INVALID), - "deferred": (EventType.DEFERRED, None), - "opened": (EventType.OPENED, None), # see also unique_opened below - "click": (EventType.CLICKED, None), - "unsubscribe": (EventType.UNSUBSCRIBED, None), - # shouldn't occur for transactional messages: - "list_addition": (EventType.SUBSCRIBED, None), - "unique_opened": (EventType.OPENED, None), # first open; see also opened above - } - - def esp_to_anymail_event(self, esp_event): - esp_type = esp_event.get("event") - event_type, reject_reason = self.event_types.get( - esp_type, (EventType.UNKNOWN, None) - ) - recipient = esp_event.get("email") - - try: - # SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be - # based on the timezone set in the account preferences (and possibly with - # inconsistent DST adjustment). "ts_epoch" is the only field that seems to - # be consistently UTC; it's in milliseconds - timestamp = datetime.fromtimestamp( - esp_event["ts_epoch"] / 1000.0, tz=timezone.utc - ) - except (KeyError, ValueError): - timestamp = None - - tags = [] - try: - # If `tags` param set on send, webhook payload includes 'tags' array field. - tags = esp_event["tags"] - except KeyError: - try: - # If `X-Mailin-Tag` header set on send, webhook payload includes single - # 'tag' string. (If header not set, webhook 'tag' will be the template - # name for template sends.) - tags = [esp_event["tag"]] - except KeyError: - pass - - try: - metadata = json.loads(esp_event["X-Mailin-custom"]) - except (KeyError, TypeError): - metadata = {} - - return AnymailTrackingEvent( - description=None, - esp_event=esp_event, - # SendinBlue doesn't provide a unique event id: - event_id=None, - event_type=event_type, - message_id=esp_event.get("message-id"), - metadata=metadata, - mta_response=esp_event.get("reason"), - recipient=recipient, - reject_reason=reject_reason, - tags=tags, - timestamp=timestamp, - user_agent=None, - click_url=esp_event.get("link"), - ) - - -class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView): - """Handler for SendinBlue inbound email webhooks""" - - signal = inbound - def __init__(self, **kwargs): - super().__init__(**kwargs) - # API is required to fetch inbound attachment content: - self.api_key = get_anymail_setting( - "api_key", - esp_name=self.esp_name, - kwargs=kwargs, - allow_bare=True, - ) - self.api_url = get_anymail_setting( - "api_url", - esp_name=self.esp_name, - kwargs=kwargs, - default="https://api.brevo.com/v3/", - ) - if not self.api_url.endswith("/"): - self.api_url += "/" - - def parse_events(self, request): - payload = json.loads(request.body.decode("utf-8")) - try: - esp_events = payload["items"] - except KeyError: - # This is not n inbound webhook post - raise AnymailConfigurationError( - "You seem to have set SendinBlue's *tracking* webhook URL " - "to Anymail's SendinBlue *inbound* webhook URL." - ) - else: - return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] - - def esp_to_anymail_event(self, esp_event): - # Inbound event's "Uuid" is documented as - # "A list of recipients UUID (can be used with the Public API)". - # In practice, it seems to be a single-item list (even when sending - # to multiple inbound recipients at once) that uniquely identifies this - # inbound event. (And works as a param for the /inbound/events/{uuid} API - # that will "Fetch all events history for one particular received email.") - try: - event_id = esp_event["Uuid"][0] - except (KeyError, IndexError): - event_id = None - - attachments = [ - self._fetch_attachment(attachment) - for attachment in esp_event.get("Attachments", []) - ] - headers = [ - (name, value) - for name, values in esp_event.get("Headers", {}).items() - # values is string if single header instance, list of string if multiple - for value in ([values] if isinstance(values, str) else values) - ] - - # (esp_event From, To, Cc, ReplyTo, Subject, Date, etc. are also in Headers) - message = AnymailInboundMessage.construct( - headers=headers, - text=esp_event.get("RawTextBody", ""), - html=esp_event.get("RawHtmlBody", ""), - attachments=attachments, + warnings.warn( + "Anymail's SendinBlue webhook URLs are deprecated." + " Update your Brevo transactional email webhook URL to change" + " 'anymail/sendinblue' to 'anymail/brevo'.", + AnymailDeprecationWarning, ) + super().__init__(**kwargs) - if message["Return-Path"]: - message.envelope_sender = unquote(message["Return-Path"]) - if message["Delivered-To"]: - message.envelope_recipient = unquote(message["Delivered-To"]) - message.stripped_text = esp_event.get("ExtractedMarkdownMessage") - - # Documented as "Spam.Score" object, but both example payload - # and actual received payload use single "SpamScore" field: - message.spam_score = esp_event.get("SpamScore") - return AnymailInboundEvent( - event_type=EventType.INBOUND, - timestamp=None, # Brevo doesn't provide inbound event timestamp - event_id=event_id, - esp_event=esp_event, - message=message, - ) +class SendinBlueInboundWebhookView(BrevoInboundWebhookView): + """ + Deprecated compatibility inbound webhook for old Brevo name "SendinBlue". + """ - def _fetch_attachment(self, attachment): - # Download attachment content from SendinBlue API. - # FUTURE: somehow defer download until attachment is accessed? - token = attachment["DownloadToken"] - url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}") - response = requests.get(url, headers={"api-key": self.api_key}) - response.raise_for_status() # or maybe just log and continue? + esp_name = "SendinBlue" - content = response.content - # Prefer response Content-Type header to attachment ContentType field, - # as the header will include charset but the ContentType field won't. - content_type = response.headers.get("Content-Type") or attachment["ContentType"] - return AnymailInboundMessage.construct_attachment( - content_type=content_type, - content=content, - filename=attachment.get("Name"), - content_id=attachment.get("ContentID"), + def __init__(self, **kwargs): + warnings.warn( + "Anymail's SendinBlue webhook URLs are deprecated." + " Update your Brevo inbound webhook URL to change" + " 'anymail/sendinblue' to 'anymail/brevo'.", + AnymailDeprecationWarning, ) + super().__init__(**kwargs) diff --git a/docs/esps/brevo.rst b/docs/esps/brevo.rst index acba2773..1e1be7e0 100644 --- a/docs/esps/brevo.rst +++ b/docs/esps/brevo.rst @@ -4,14 +4,24 @@ Brevo ===== +.. Docs note: esps/sendinblue is redirected to esps/brevo in ReadTheDocs config. + Please preserve existing _sendinblue-* ref labels, so that redirected link + anchors work properly (in old links from external sites). E.g.: + an old link: https://anymail.dev/en/stable/esps/sendinblue#sendinblue-templates + redirects to: https://anymail.dev/en/stable/esps/brevo#sendinblue-templates + which is also: https://anymail.dev/en/stable/esps/brevo#brevo-templates + (There's no need to create _sendinblue-* duplicates of any new _brevo-* labels.) + Anymail integrates with the `Brevo`_ email service (formerly Sendinblue), using their `API v3`_. Brevo's transactional API does not support some basic email features, such as -inline images. Be sure to review the :ref:`limitations ` below. +inline images. Be sure to review the :ref:`limitations ` below. -.. versionchanged:: 10.1 +.. versionchanged:: 10.3 - Brevo was called "Sendinblue" until May, 2023. To avoid unnecessary code changes, - Anymail still uses the old name in code (settings, backend, webhook urls, etc.). + SendinBlue rebranded as Brevo in May, 2023. Anymail 10.3 uses the new + name throughout its code; earlier versions used the old name. Code that + refers to "SendinBlue" should continue to work, but is now deprecated. + See :ref:`brevo-rename` for details. .. important:: @@ -36,14 +46,14 @@ To use Anymail's Brevo backend, set: .. code-block:: python - EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" + EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend" in your settings.py. -.. setting:: ANYMAIL_SENDINBLUE_API_KEY +.. setting:: ANYMAIL_BREVO_API_KEY -.. rubric:: SENDINBLUE_API_KEY +.. rubric:: BREVO_API_KEY The API key can be retrieved from your Brevo `SMTP & API settings`_ on the "API Keys" tab (don't try to use an SMTP key). Required. @@ -55,23 +65,23 @@ Anymail. If you don't see a v3 key listed, use "Create a New API Key".) ANYMAIL = { ... - "SENDINBLUE_API_KEY": "", + "BREVO_API_KEY": "", } -Anymail will also look for ``SENDINBLUE_API_KEY`` at the -root of the settings file if neither ``ANYMAIL["SENDINBLUE_API_KEY"]`` -nor ``ANYMAIL_SENDINBLUE_API_KEY`` is set. +Anymail will also look for ``BREVO_API_KEY`` at the +root of the settings file if neither ``ANYMAIL["BREVO_API_KEY"]`` +nor ``ANYMAIL_BREVO_API_KEY`` is set. .. _SMTP & API settings: https://app.brevo.com/settings/keys/api -.. setting:: ANYMAIL_SENDINBLUE_API_URL +.. setting:: ANYMAIL_BREVO_API_URL -.. rubric:: SENDINBLUE_API_URL +.. rubric:: BREVO_API_URL The base url for calling the Brevo API. -The default is ``SENDINBLUE_API_URL = "https://api.brevo.com/v3/"`` +The default is ``BREVO_API_URL = "https://api.brevo.com/v3/"`` (It's unlikely you would need to change this.) .. versionchanged:: 10.1 @@ -79,6 +89,7 @@ The default is ``SENDINBLUE_API_URL = "https://api.brevo.com/v3/"`` Earlier Anymail releases used ``https://api.sendinblue.com/v3/``. +.. _brevo-esp-extra: .. _sendinblue-esp-extra: esp_extra support @@ -106,6 +117,7 @@ to apply it to all messages.) .. _smtp/email API: https://developers.brevo.com/reference/sendtransacemail +.. _brevo-limitations: .. _sendinblue-limitations: Limitations and quirks @@ -192,6 +204,7 @@ Brevo can handle. on individual messages. +.. _brevo-templates: .. _sendinblue-templates: Batch sending/merge and ESP templates @@ -267,9 +280,9 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``. .. caution:: - **Sendinblue "old template language" not supported** + **"Old template language" not supported** - Sendinblue once supported two different template styles: a "new" template + Brevo once supported two different template styles: a "new" template language that uses Django-like template syntax (with ``{{ param.NAME }}`` substitutions), and an "old" template language that used percent-delimited ``%NAME%`` substitutions. @@ -299,17 +312,18 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``. https://help.brevo.com/hc/en-us/articles/360000991960 +.. _brevo-webhooks: .. _sendinblue-webhooks: Status tracking webhooks ------------------------ If you are using Anymail's normalized :ref:`status tracking `, add -the url at Brevo's site under `Transactional > Email > Settings > Webhook`_. +the url at Brevo's site under `Transactional > Email > Settings > Webhook`_. The "URL to call" is: - :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendinblue/tracking/` + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/brevo/tracking/` * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site @@ -336,10 +350,17 @@ For example, it's not uncommon to receive a "delivered" event before the corresp The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be a `dict` of raw webhook data received from Brevo. +.. versionchanged:: 10.3 + + Older Anymail versions used a tracking webhook URL containing "sendinblue" rather + than "brevo". The old URL will still work, but is deprecated. See :ref:`brevo-rename` + below. + .. _Transactional > Email > Settings > Webhook: https://app-smtp.brevo.com/webhook +.. _brevo-inbound: .. _sendinblue-inbound: Inbound webhook @@ -353,7 +374,7 @@ guide to enable inbound service and add Anymail's inbound webhook. At the "Creating the webhook" step, set the ``"url"`` param to: - :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendinblue/inbound/` + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/brevo/inbound/` * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *yoursite.example.com* is your Django site @@ -364,6 +385,12 @@ by entering your API key in "Header" field above the example, and then clicking "Try It!". The `webhooks management APIs`_ and `inbound events list API`_ can be helpful for diagnosing inbound issues. +.. versionchanged:: 10.3 + + Older Anymail versions used an inbound webhook URL containing "sendinblue" rather + than "brevo". The old URL will still work, but is deprecated. See :ref:`brevo-rename` + below. + .. _Inbound parsing webhooks: https://developers.brevo.com/docs/inbound-parse-webhooks @@ -371,3 +398,101 @@ be helpful for diagnosing inbound issues. https://developers.brevo.com/reference/getwebhooks-1 .. _inbound events list API: https://developers.brevo.com/reference/getinboundemailevents + + +.. _brevo-rename: + +Updating code from SendinBlue to Brevo +-------------------------------------- + +SendinBlue rebranded as Brevo in May, 2023. Anymail 10.3 has switched +to the new name. + +If your code refers to the old "sendinblue" name +(in :setting:`!EMAIL_BACKEND` and :setting:`!ANYMAIL` settings, :attr:`!esp_name` +checks, or elsewhere) you should update it to use "brevo" instead. +If you are using Anymail's tracking or inbound webhooks, you should +also update the webhook URLs you've configured at Brevo. + +For compatibility, code and URLs using the old name are still functional in Anymail. +But they will generate deprecation warnings, and may be removed in a future release. + +To update your code: + +.. setting:: ANYMAIL_SENDINBLUE_API_KEY +.. setting:: ANYMAIL_SENDINBLUE_API_URL + +1. In your settings.py, update the :setting:`!EMAIL_BACKEND` + and rename any ``"SENDINBLUE_..."`` settings to ``"BREVO_..."``: + + .. code-block:: diff + + - EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" # old + + EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend" # new + + ANYMAIL = { + ... + - "SENDINBLUE_API_KEY": "", # old + + "BREVO_API_KEY": "", # new + # (Also change "SENDINBLUE_API_URL" to "BREVO_API_URL" if present) + + # If you are using Brevo-specific global send defaults, change: + - "SENDINBLUE_SEND_DEFAULTS" = {...}, # old + + "BREVO_SEND_DEFAULTS" = {...}, # new + } + +2. If you are using Anymail's status tracking webhook, + go to Brevo's dashboard (under `Transactional > Email > Settings > Webhook`_), + and change the end or the URL from ``.../anymail/sendinblue/tracking/`` + to ``.../anymail/brevo/tracking/``. (Or use the code below to automate this.) + + In your :ref:`tracking signal receiver function `, + if you are examining the ``esp_name`` parameter, the name will change + once you have updated the webhook URL. If you had been checking + whether ``esp_name == "SendinBlue"``, change that to check if + ``esp_name == "Brevo"``. + +3. If you are using Anymail's inbound handling, update the inbound webhook + URL to change ``.../anymail/sendinblue/inbound/`` to ``.../anymail/brevo/inbound/``. + You will need to use Brevo's webhooks API to make the change---see below. + + In your :ref:`inbound signal receiver function `, + if you are examining the ``esp_name`` parameter, the name will change + once you have updated the webhook URL. If you had been checking + whether ``esp_name == "SendinBlue"``, change that to check if + ``esp_name == "Brevo"``. + +That should be everything, but to double check you may want to search your +code for any remaining references to "sendinblue" (case-insensitive). +(E.g., ``grep -r -i sendinblue``.) + +To update both the tracking and inbound webhook URLs using Brevo's `webhooks API`_, +you could run something like this Python code: + +.. code-block:: python + + # Update Brevo webhook URLs to replace "anymail/sendinblue" with "anymail/brevo". + import requests + BREVO_API_KEY = "" + + headers = { + "accept": "application/json", + "api-key": BREVO_API_KEY, + } + + response = requests.get("https://api.brevo.com/v3/webhooks", headers=headers) + response.raise_for_status() + webhooks = response.json() + + for webhook in webhooks: + if "anymail/sendinblue" in webhook["url"]: + response = requests.put( + f"https://api.brevo.com/v3/webhooks/{webhook['id']}", + headers=headers, + json={ + "url": webhook["url"].replace("anymail/sendinblue", "anymail/brevo") + } + ) + response.raise_for_status() + +.. _webhooks API: https://developers.brevo.com/reference/updatewebhook-1 diff --git a/pyproject.toml b/pyproject.toml index 1d50510b..d91c3294 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ # (For simplicity, requests is included in the base dependencies.) # (Do not use underscores in extra names: they get normalized to hyphens.) amazon-ses = ["boto3"] +brevo = [] mailersend = [] mailgun = [] mailjet = [] diff --git a/tests/test_sendinblue_backend.py b/tests/test_brevo_backend.py similarity index 93% rename from tests/test_sendinblue_backend.py rename to tests/test_brevo_backend.py index d1290c78..2d5fa0f7 100644 --- a/tests/test_sendinblue_backend.py +++ b/tests/test_brevo_backend.py @@ -32,19 +32,16 @@ ) -@tag("sendinblue") +@tag("brevo") @override_settings( - EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", - ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"}, + EMAIL_BACKEND="anymail.backends.brevo.EmailBackend", + ANYMAIL={"BREVO_API_KEY": "test_api_key"}, ) -class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase): - # SendinBlue v3 success responses are empty +class BrevoBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = ( b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' ) - DEFAULT_STATUS_CODE = ( - 201 # SendinBlue v3 uses '201 Created' for success (in most cases) - ) + DEFAULT_STATUS_CODE = 201 # Brevo v3 uses '201 Created' for success (in most cases) def setUp(self): super().setUp() @@ -54,8 +51,8 @@ def setUp(self): ) -@tag("sendinblue") -class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): +@tag("brevo") +class BrevoBackendStandardEmailTests(BrevoBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): @@ -204,7 +201,7 @@ def test_reply_to(self): ) def test_multiple_reply_to(self): - # SendinBlue v3 only allows a single reply address + # Brevo v3 only allows a single reply address self.message.reply_to = [ '"Reply recipient" "], reply_to=["Recipient "], ) - # SendinBlue uses per-account numeric ID to identify templates: + # Brevo uses per-account numeric ID to identify templates: message.template_id = 12 message.send() data = self.get_api_call_json() @@ -603,7 +600,7 @@ def test_esp_extra(self): # 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 SendinBlue + # the DEFAULT_RAW_RESPONSE above is the *only* success response Brevo # returns, so no need to override it here msg = mail.EmailMessage( "Subject", @@ -652,39 +649,37 @@ def test_json_serialization_errors(self): err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps # our added context: - self.assertIn("Don't know how to send this data to SendinBlue", str(err)) + self.assertIn("Don't know how to send this data to Brevo", str(err)) # original message self.assertRegex(str(err), r"Decimal.*is not JSON serializable") -@tag("sendinblue") -class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase): +@tag("brevo") +class BrevoBackendRecipientsRefusedTests(BrevoBackendMockAPITestCase): """ Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid """ - # SendinBlue doesn't check email bounce or complaint lists at time of send -- + # Brevo 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 # not applicable to this backend -@tag("sendinblue") -class SendinBlueBackendSessionSharingTestCase( - SessionSharingTestCases, SendinBlueBackendMockAPITestCase +@tag("brevo") +class BrevoBackendSessionSharingTestCase( + SessionSharingTestCases, BrevoBackendMockAPITestCase ): """Requests session sharing tests""" pass # tests are defined in SessionSharingTestCases -@tag("sendinblue") -@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") -class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): +@tag("brevo") +@override_settings(EMAIL_BACKEND="anymail.backends.brevo.EmailBackend") +class BrevoBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_auth(self): - with self.assertRaisesRegex( - AnymailConfigurationError, r"\bSENDINBLUE_API_KEY\b" - ): + with self.assertRaisesRegex(AnymailConfigurationError, r"\bBREVO_API_KEY\b"): mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) diff --git a/tests/test_sendinblue_inbound.py b/tests/test_brevo_inbound.py similarity index 92% rename from tests/test_sendinblue_inbound.py rename to tests/test_brevo_inbound.py index 0e7e65eb..7dddc6eb 100644 --- a/tests/test_sendinblue_inbound.py +++ b/tests/test_brevo_inbound.py @@ -7,15 +7,15 @@ from anymail.exceptions import AnymailConfigurationError from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent -from anymail.webhooks.sendinblue import SendinBlueInboundWebhookView +from anymail.webhooks.brevo import BrevoInboundWebhookView from .utils import sample_email_content, sample_image_content from .webhook_cases import WebhookTestCase -@tag("sendinblue") -@override_settings(ANYMAIL_SENDINBLUE_API_KEY="test-api-key") -class SendinBlueInboundTestCase(WebhookTestCase): +@tag("brevo") +@override_settings(ANYMAIL_BREVO_API_KEY="test-api-key") +class BrevoInboundTestCase(WebhookTestCase): def test_inbound_basics(self): # Actual (sanitized) Brevo inbound message payload 7/2023 raw_event = { @@ -54,16 +54,16 @@ def test_inbound_basics(self): } response = self.client.post( - "/anymail/sendinblue/inbound/", + "/anymail/brevo/inbound/", content_type="application/json", data={"items": [raw_event]}, ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.inbound_handler, - sender=SendinBlueInboundWebhookView, + sender=BrevoInboundWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) # AnymailInboundEvent event = kwargs["event"] @@ -123,15 +123,15 @@ def test_envelope_attrs(self): } } self.client.post( - "/anymail/sendinblue/inbound/", + "/anymail/brevo/inbound/", content_type="application/json", data={"items": [raw_event]}, ) kwargs = self.assert_handler_called_once_with( self.inbound_handler, - sender=SendinBlueInboundWebhookView, + sender=BrevoInboundWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] message = event.message @@ -203,16 +203,16 @@ def test_attachments(self): ) response = self.client.post( - "/anymail/sendinblue/inbound/", + "/anymail/brevo/inbound/", content_type="application/json", data={"items": [raw_event]}, ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.inbound_handler, - sender=SendinBlueInboundWebhookView, + sender=BrevoInboundWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] message = event.message @@ -235,12 +235,12 @@ def test_attachments(self): def test_misconfigured_tracking(self): errmsg = ( - "You seem to have set SendinBlue's *tracking* webhook URL" - " to Anymail's SendinBlue *inbound* webhook URL." + "You seem to have set Brevo's *tracking* webhook URL" + " to Anymail's Brevo *inbound* webhook URL." ) with self.assertRaisesMessage(AnymailConfigurationError, errmsg): self.client.post( - "/anymail/sendinblue/inbound/", + "/anymail/brevo/inbound/", content_type="application/json", data={"event": "delivered"}, ) diff --git a/tests/test_sendinblue_integration.py b/tests/test_brevo_integration.py similarity index 73% rename from tests/test_sendinblue_integration.py rename to tests/test_brevo_integration.py index 0acdb2dd..c9d472f5 100644 --- a/tests/test_sendinblue_integration.py +++ b/tests/test_brevo_integration.py @@ -10,39 +10,39 @@ from .utils import AnymailTestMixin -ANYMAIL_TEST_SENDINBLUE_API_KEY = os.getenv("ANYMAIL_TEST_SENDINBLUE_API_KEY") -ANYMAIL_TEST_SENDINBLUE_DOMAIN = os.getenv("ANYMAIL_TEST_SENDINBLUE_DOMAIN") +ANYMAIL_TEST_BREVO_API_KEY = os.getenv("ANYMAIL_TEST_BREVO_API_KEY") +ANYMAIL_TEST_BREVO_DOMAIN = os.getenv("ANYMAIL_TEST_BREVO_DOMAIN") -@tag("sendinblue", "live") +@tag("brevo", "live") @unittest.skipUnless( - ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN, - "Set ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN " - "environment variables to run SendinBlue integration tests", + ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN, + "Set ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN " + "environment variables to run Brevo integration tests", ) @override_settings( - ANYMAIL_SENDINBLUE_API_KEY=ANYMAIL_TEST_SENDINBLUE_API_KEY, - ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), - EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", + ANYMAIL_BREVO_API_KEY=ANYMAIL_TEST_BREVO_API_KEY, + ANYMAIL_BREVO_SEND_DEFAULTS=dict(), + EMAIL_BACKEND="anymail.backends.brevo.EmailBackend", ) -class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): - """SendinBlue v3 API integration tests +class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """Brevo v3 API integration tests - SendinBlue doesn't have sandbox so these tests run - against the **live** SendinBlue API, using the - environment variable `ANYMAIL_TEST_SENDINBLUE_API_KEY` as the API key, - and `ANYMAIL_TEST_SENDINBLUE_DOMAIN` to construct sender addresses. + Brevo doesn't have sandbox so these tests run + against the **live** Brevo API, using the + environment variable `ANYMAIL_TEST_BREVO_API_KEY` as the API key, + and `ANYMAIL_TEST_BREVO_DOMAIN` to construct sender addresses. If those variables are not set, these tests won't run. - https://developers.sendinblue.com/docs/faq#section-how-can-i-test-the-api- + https://developers.brevo.com/docs/faq#how-can-i-test-the-api """ def setUp(self): super().setUp() - self.from_email = "from@%s" % ANYMAIL_TEST_SENDINBLUE_DOMAIN + self.from_email = "from@%s" % ANYMAIL_TEST_BREVO_DOMAIN self.message = AnymailMessage( - "Anymail SendinBlue integration test", + "Anymail Brevo integration test", "Text content", self.from_email, ["test+to1@anymail.dev"], @@ -50,7 +50,7 @@ def setUp(self): self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): - # Example of getting the SendinBlue send status and message id from the message + # Example of getting the Brevo send status and message id from the message sent_count = self.message.send() self.assertEqual(sent_count, 1) @@ -58,7 +58,7 @@ def test_simple_send(self): sent_status = anymail_status.recipients["test+to1@anymail.dev"].status message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, "queued") # SendinBlue always queues + self.assertEqual(sent_status, "queued") # Brevo always queues # Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com: self.assertRegex(message_id, r"\<.+@.+\>") # set of all recipient statuses: @@ -68,27 +68,27 @@ def test_simple_send(self): def test_all_options(self): send_at = datetime.now() + timedelta(minutes=2) message = AnymailMessage( - subject="Anymail SendinBlue all-options integration test", + subject="Anymail Brevo all-options integration test", body="This is the text body", from_email=formataddr(("Test From, with comma", self.from_email)), to=["test+to1@anymail.dev", '"Recipient 2, OK?" '], cc=["test+cc1@anymail.dev", "Copy 2 "], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], - # SendinBlue API v3 only supports single reply-to + # Brevo API v3 only supports single reply-to reply_to=['"Reply, with comma" '], headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1", "tag 2"], ) - # SendinBlue requires an HTML body: + # Brevo requires an HTML body: message.attach_alternative("

HTML content

", "text/html") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.send() - # SendinBlue always queues: + # Brevo always queues: self.assertEqual(message.anymail_status.status, {"queued"}) self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>") @@ -118,7 +118,7 @@ def test_template(self): message.attach("attachment1.txt", "Here is some\ntext", "text/plain") message.send() - # SendinBlue always queues: + # Brevo always queues: self.assertEqual(message.anymail_status.status, {"queued"}) recipient_status = message.anymail_status.recipients self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") @@ -135,11 +135,11 @@ def test_template(self): recipient_status["test+to2@anymail.dev"].message_id, ) - @override_settings(ANYMAIL_SENDINBLUE_API_KEY="Hey, that's not an API key!") + @override_settings(ANYMAIL_BREVO_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, 401) - # Make sure the exception message includes SendinBlue's response: + # Make sure the exception message includes Brevo's response: self.assertIn("Key not found", str(err)) diff --git a/tests/test_sendinblue_webhooks.py b/tests/test_brevo_webhooks.py similarity index 82% rename from tests/test_sendinblue_webhooks.py rename to tests/test_brevo_webhooks.py index a2b224c6..c29539ac 100644 --- a/tests/test_sendinblue_webhooks.py +++ b/tests/test_brevo_webhooks.py @@ -6,16 +6,16 @@ from anymail.exceptions import AnymailConfigurationError from anymail.signals import AnymailTrackingEvent -from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView +from anymail.webhooks.brevo import BrevoTrackingWebhookView from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag("sendinblue") -class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase): +@tag("brevo") +class BrevoWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): return self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps({}), ) @@ -23,23 +23,22 @@ def call_webhook(self): # Actual tests are in WebhookBasicAuthTestCase -@tag("sendinblue") -class SendinBlueDeliveryTestCase(WebhookTestCase): - # SendinBlue's webhook payload data is partially documented at - # https://help.sendinblue.com/hc/en-us/articles/360007666479, - # but it's not completely up to date. +@tag("brevo") +class BrevoDeliveryTestCase(WebhookTestCase): + # Brevo's webhook payload data is documented at + # https://developers.brevo.com/docs/transactional-webhooks. # The payloads below were obtained through live testing. def test_sent_event(self): raw_event = { "event": "request", "email": "recipient@example.com", - "id": 9999999, # this seems to be SendinBlue account id (not an event id) + "id": 9999999, # this seems to be Brevo account id (not an event id) "message-id": "<201803062010.27287306012@smtp-relay.mailin.fr>", "subject": "Test subject", # From a message sent at 2018-03-06 11:10:23-08:00 # (2018-03-06 19:10:23+00:00)... - "date": "2018-03-06 11:10:23", # tz from SendinBlue account's preferences + "date": "2018-03-06 11:10:23", # tz from Brevo account's preferences "ts": 1520331023, # 2018-03-06 10:10:23 -- what time zone is this? "ts_event": 1520331023, # unclear if this ever differs from "ts" "ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (msec) @@ -55,16 +54,16 @@ def test_sent_event(self): "sending_ip": "333.33.33.33", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) @@ -77,7 +76,7 @@ def test_sent_event(self): self.assertEqual( event.message_id, "<201803062010.27287306012@smtp-relay.mailin.fr>" ) - # SendinBlue does not provide a unique event id: + # Brevo does not provide a unique event id: self.assertIsNone(event.event_id) self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.metadata, {"meta": "data"}) @@ -93,16 +92,16 @@ def test_delivered_event(self): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) @@ -128,16 +127,16 @@ def test_hard_bounce(self): "tag": "header-tag", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "bounced") @@ -158,16 +157,16 @@ def test_soft_bounce_event(self): "reason": "undefined Unable to find MX of domain no-mx.example.com", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "bounced") @@ -188,16 +187,16 @@ def test_blocked(self): "reason": "blocked : due to blacklist user", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "rejected") @@ -214,16 +213,16 @@ def test_spam(self): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "complained") @@ -231,7 +230,7 @@ def test_spam(self): def test_invalid_email(self): # "If a ISP again indicated us that the email is not valid or if we discovered # that the email is not valid." (unclear whether this error originates with the - # receiving MTA or with SendinBlue pre-send) (haven't observed "invalid_email" + # receiving MTA or with Brevo pre-send) (haven't observed "invalid_email" # event in actual testing; payload below is a guess) raw_event = { "event": "invalid_email", @@ -241,16 +240,16 @@ def test_invalid_email(self): "reason": "(guessing invalid_email includes a reason)", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "bounced") @@ -262,7 +261,7 @@ def test_invalid_email(self): def test_deferred_event(self): # Note: the example below is an actual event capture (with 'example.com' # substituted for the real receiving domain). It's pretty clearly a bounce, not - # a deferral. It looks like SendinBlue mis-categorizes this SMTP response code. + # a deferral. It looks like Brevo mis-categorizes this SMTP response code. raw_event = { "event": "deferred", "email": "notauser@example.com", @@ -272,16 +271,16 @@ def test_deferred_event(self): " address rejected: User unknown in virtual alias table", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "deferred") @@ -294,7 +293,7 @@ def test_deferred_event(self): ) def test_opened_event(self): - # SendinBlue delivers 'unique_opened' only on the first open, and 'opened' + # Brevo delivers 'unique_opened' only on the first open, and 'opened' # only on the second or later tracking pixel views. (But they used to deliver # both on the first open.) raw_event = { @@ -304,20 +303,20 @@ def test_opened_event(self): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "opened") - self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent + self.assertIsNone(event.user_agent) # Brevo doesn't report user agent def test_unique_opened_event(self): # See note in test_opened_event above @@ -328,16 +327,16 @@ def test_unique_opened_event(self): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "opened") @@ -351,21 +350,21 @@ def test_clicked_event(self): "link": "https://example.com/click/me", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "clicked") self.assertEqual(event.click_url, "https://example.com/click/me") - self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent + self.assertIsNone(event.user_agent) # Brevo doesn't report user agent def test_unsubscribe(self): # "When a person unsubscribes from the email received." @@ -378,28 +377,28 @@ def test_unsubscribe(self): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } response = self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data=json.dumps(raw_event), ) self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with( self.tracking_handler, - sender=SendinBlueTrackingWebhookView, + sender=BrevoTrackingWebhookView, event=ANY, - esp_name="SendinBlue", + esp_name="Brevo", ) event = kwargs["event"] self.assertEqual(event.event_type, "unsubscribed") def test_misconfigured_inbound(self): errmsg = ( - "You seem to have set SendinBlue's *inbound* webhook URL" - " to Anymail's SendinBlue *tracking* webhook URL." + "You seem to have set Brevo's *inbound* webhook URL" + " to Anymail's Brevo *tracking* webhook URL." ) with self.assertRaisesMessage(AnymailConfigurationError, errmsg): self.client.post( - "/anymail/sendinblue/tracking/", + "/anymail/brevo/tracking/", content_type="application/json", data={"items": []}, ) diff --git a/tests/test_sendinblue_deprecations.py b/tests/test_sendinblue_deprecations.py new file mode 100644 index 00000000..9f401820 --- /dev/null +++ b/tests/test_sendinblue_deprecations.py @@ -0,0 +1,117 @@ +from unittest.mock import ANY + +from django.core.mail import EmailMessage, send_mail +from django.test import ignore_warnings, override_settings, tag + +from anymail.exceptions import AnymailConfigurationError, AnymailDeprecationWarning +from anymail.webhooks.sendinblue import ( + SendinBlueInboundWebhookView, + SendinBlueTrackingWebhookView, +) + +from .mock_requests_backend import RequestsBackendMockAPITestCase +from .webhook_cases import WebhookTestCase + + +@tag("brevo", "sendinblue") +@override_settings( + EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", + ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"}, +) +@ignore_warnings(category=AnymailDeprecationWarning) +class SendinBlueBackendDeprecationTests(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = ( + b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' + ) + DEFAULT_STATUS_CODE = 201 # Brevo v3 uses '201 Created' for success (in most cases) + + def test_deprecation_warning(self): + message = EmailMessage( + "Subject", "Body", "from@example.com", ["to@example.com"] + ) + with self.assertWarnsMessage( + AnymailDeprecationWarning, + "`anymail.backends.sendinblue.EmailBackend` has been renamed" + " `anymail.backends.brevo.EmailBackend`.", + ): + message.send() + self.assert_esp_called("https://api.brevo.com/v3/smtp/email") + + @override_settings(ANYMAIL={"BREVO_API_KEY": "test_api_key"}) + def test_missing_api_key_error_uses_correct_setting_name(self): + # The sendinblue.EmailBackend requires SENDINBLUE_ settings names + with self.assertRaisesMessage(AnymailConfigurationError, "SENDINBLUE_API_KEY"): + send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) + + +@tag("brevo", "sendinblue") +@ignore_warnings(category=AnymailDeprecationWarning) +class SendinBlueTrackingWebhookDeprecationTests(WebhookTestCase): + def test_deprecation_warning(self): + with self.assertWarnsMessage( + AnymailDeprecationWarning, + "Anymail's SendinBlue webhook URLs are deprecated.", + ): + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data="{}", + ) + self.assertEqual(response.status_code, 200) + # Old url uses old names to preserve compatibility: + self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, # *not* BrevoTrackingWebhookView + event=ANY, + esp_name="SendinBlue", # *not* "Brevo" + ) + + def test_misconfigured_inbound(self): + # Uses old esp_name when called on old URL + errmsg = ( + "You seem to have set Brevo's *inbound* webhook URL" + " to Anymail's SendinBlue *tracking* webhook URL." + ) + with self.assertRaisesMessage(AnymailConfigurationError, errmsg): + self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data={"items": []}, + ) + + +@tag("brevo", "sendinblue") +@override_settings(ANYMAIL_SENDINBLUE_API_KEY="test-api-key") +@ignore_warnings(category=AnymailDeprecationWarning) +class SendinBlueInboundWebhookDeprecationTests(WebhookTestCase): + def test_deprecation_warning(self): + with self.assertWarnsMessage( + AnymailDeprecationWarning, + "Anymail's SendinBlue webhook URLs are deprecated.", + ): + response = self.client.post( + "/anymail/sendinblue/inbound/", + content_type="application/json", + data='{"items":[{}]}', + ) + self.assertEqual(response.status_code, 200) + # Old url uses old names to preserve compatibility: + self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendinBlueInboundWebhookView, # *not* BrevoInboundWebhookView + event=ANY, + esp_name="SendinBlue", # *not* "Brevo" + ) + + def test_misconfigured_tracking(self): + # Uses old esp_name when called on old URL + errmsg = ( + "You seem to have set Brevo's *tracking* webhook URL" + " to Anymail's SendinBlue *inbound* webhook URL." + ) + with self.assertRaisesMessage(AnymailConfigurationError, errmsg): + self.client.post( + "/anymail/sendinblue/inbound/", + content_type="application/json", + data={"event": "delivered"}, + ) diff --git a/tox.ini b/tox.ini index 97dd7061..1579487b 100644 --- a/tox.ini +++ b/tox.ini @@ -59,6 +59,7 @@ setenv = # (resend should work with or without its extras, so it isn't in `none`) none: ANYMAIL_SKIP_TESTS=amazon_ses,postal amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses + brevo: ANYMAIL_ONLY_TEST=brevo mailersend: ANYMAIL_ONLY_TEST=mailersend mailgun: ANYMAIL_ONLY_TEST=mailgun mailjet: ANYMAIL_ONLY_TEST=mailjet @@ -68,7 +69,6 @@ setenv = resend: ANYMAIL_ONLY_TEST=resend sendgrid: ANYMAIL_ONLY_TEST=sendgrid unisender_go: ANYMAIL_ONLY_TEST=unisender_go - sendinblue: ANYMAIL_ONLY_TEST=sendinblue sparkpost: ANYMAIL_ONLY_TEST=sparkpost ignore_outcome = # CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false