Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Brevo: Rename SendinBlue to Brevo #364

Merged
merged 2 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }

Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://anymail.dev/en/latest/esps/brevo/#brevo-rename>`__
for details.


Features
~~~~~~~~

Expand Down
220 changes: 220 additions & 0 deletions anymail/backends/brevo.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading