import mimetypes from ..exceptions import AnymailRequestsAPIError, AnymailUnsupportedFeature from ..message import AnymailRecipientStatus from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting, update_deep from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ MailerSend Email Backend """ esp_name = "MailerSend" def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name self.api_token = get_anymail_setting( "api_token", 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.mailersend.com/v1/", ) if not api_url.endswith("/"): api_url += "/" #: Can set to "use-bulk-email" or "expose-to-list" or default None self.batch_send_mode = get_anymail_setting( "batch_send_mode", default=None, esp_name=esp_name, kwargs=kwargs ) super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailerSendPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): # The "email" API endpoint responds with an empty text/html body # if no warnings, otherwise json with suppression info. # The "bulk-email" API endpoint always returns json. if response.headers["Content-Type"] == "application/json": parsed_response = self.deserialize_json_response(response, payload, message) else: parsed_response = {} try: # "email" API endpoint success or SOME_SUPPRESSED message_id = response.headers["X-Message-Id"] default_status = "queued" except KeyError: try: # "bulk-email" API endpoint bulk_id = parsed_response["bulk_email_id"] # Add "bulk:" prefix to distinguish from actual message_id. message_id = f"bulk:{bulk_id}" # Status is determined later; must query API to find out default_status = "unknown" except KeyError: # "email" API endpoint with ALL_SUPPRESSED message_id = None default_status = "failed" # Don't swallow errors (which should have been handled with a non-2xx # status, earlier) or any warnings that we won't consume below. errors = parsed_response.get("errors", []) warnings = parsed_response.get("warnings", []) if errors or any( warning["type"] not in ("ALL_SUPPRESSED", "SOME_SUPPRESSED") for warning in warnings ): raise AnymailRequestsAPIError( "Unexpected MailerSend API response errors/warnings", email_message=message, payload=payload, response=response, backend=self, ) # Collect a list of all problem recipients from any suppression warnings. # (warnings[].recipients[].reason[] will contain some combination of # "hard_bounced", "spam_complaint", "unsubscribed", and/or # "blocklisted", all of which map to Anymail's "rejected" status.) try: # warning["type"] is guaranteed to be {ALL,SOME}_SUPPRESSED at this point. rejected_emails = [ recipient["email"] for warning in warnings for recipient in warning["recipients"] ] except (KeyError, TypeError) as err: raise AnymailRequestsAPIError( f"Unexpected MailerSend API response format: {err!s}", email_message=message, payload=payload, response=response, backend=self, ) from None recipient_status = CaseInsensitiveCasePreservingDict( { recipient.addr_spec: AnymailRecipientStatus( message_id=message_id, status=default_status ) for recipient in payload.all_recipients } ) for rejected_email in rejected_emails: recipient_status[rejected_email] = AnymailRecipientStatus( message_id=None, status="rejected" ) return dict(recipient_status) class MailerSendPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): headers = { "Content-Type": "application/json", "Accept": "application/json", # Token may be changed in set_esp_extra below: "Authorization": f"Bearer {backend.api_token}", } self.all_recipients = [] # needed for parse_recipient_status self.merge_data = {} # late bound self.merge_global_data = None # late bound self.batch_send_mode = backend.batch_send_mode # can override in esp_extra super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): if self.is_batch(): # MailerSend's "email" endpoint supports per-recipient customizations # (merge_data) for batch sending, but exposes the complete "To" list # to all recipients. This conflicts with Anymail's batch send model, which # expects each recipient can only see their own "To" email. # # MailerSend's "bulk-email" endpoint can send separate messages to each # "To" email, but doesn't return a message_id. (It returns a batch_email_id # that can later resolve to message_ids by polling a status API.) # # Since either of these would cause unexpected behavior, require the user # to opt into one via batch_send_mode. if self.batch_send_mode == "use-bulk-email": return "bulk-email" elif self.batch_send_mode == "expose-to-list": return "email" elif len(self.data["to"]) <= 1: # With only one "to", exposing the recipient list is moot. # (This covers the common case of single-recipient template merge.) return "email" else: # Unconditionally raise, even if IGNORE_UNSUPPORTED_FEATURES enabled. # We can't guess which API to use for this send. raise AnymailUnsupportedFeature( f"{self.esp_name} requires MAILERSEND_BATCH_SEND_MODE set to either" f" 'use-bulk-email' or 'expose-to-list' for using batch send" f" (merge_data) with multiple recipients. See the Anymail docs." ) else: return "email" def serialize_data(self): api_endpoint = self.get_api_endpoint() needs_personalization = self.merge_data or self.merge_global_data if api_endpoint == "email": if needs_personalization: self.data["personalization"] = [ self.personalization_for_email(to["email"]) for to in self.data["to"] ] data = self.data elif api_endpoint == "bulk-email": # Burst the payload into individual bulk-email recipients: data = [] for to in self.data["to"]: recipient_data = self.data.copy() recipient_data["to"] = [to] if needs_personalization: recipient_data["personalization"] = [ self.personalization_for_email(to["email"]) ] data.append(recipient_data) else: raise AssertionError( f"MailerSendPayload.serialize_data missing" f" case for api_endpoint {api_endpoint!r}" ) return self.serialize_json(data) def personalization_for_email(self, email): """ Return a MailerSend personalization object for email address. Composes merge_global_data and merge_data[email]. """ if email in self.merge_data: if self.merge_global_data: recipient_data = self.merge_global_data.copy() recipient_data.update(self.merge_data[email]) else: recipient_data = self.merge_data[email] elif self.merge_global_data: recipient_data = self.merge_global_data else: recipient_data = {} return {"email": email, "data": recipient_data} # # Payload construction # def make_mailersend_email(self, email): """Return MailerSend email/name object for an EmailAddress""" obj = {"email": email.addr_spec} if email.display_name: obj["name"] = email.display_name return obj def init_payload(self): self.data = {} # becomes json def set_from_email(self, email): self.data["from"] = self.make_mailersend_email(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [ self.make_mailersend_email(email) for email in emails ] self.all_recipients += emails def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails): if len(emails) > 1: self.unsupported_feature("multiple reply_to emails") elif emails: self.data["reply_to"] = self.make_mailersend_email(emails[0]) def set_extra_headers(self, headers): # MailerSend has individual API params for In-Reply-To and Precedence: bulk. # The general "headers" option "is available to Enterprise accounts only". # (headers is a CaseInsensitiveDict, and is a copy so safe to modify.) in_reply_to = headers.pop("In-Reply-To", None) if in_reply_to is not None: self.data["in_reply_to"] = in_reply_to precedence = headers.pop("Precedence", None) if precedence is not None: # Overrides MailerSend domain-level setting is_bulk = precedence.lower() in ("bulk", "junk", "list") self.data["precedence_bulk"] = is_bulk if headers: self.data["headers"] = [ {"name": field, "value": value} for field, value 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): # Add a MailerSend attachments[] object for attachment: attachment_object = { "filename": attachment.name, "content": attachment.b64content, "disposition": "attachment", } if not attachment_object["filename"]: # MailerSend requires filename, and determines mimetype from it # (even for inline attachments). For unnamed attachments, try # to generate a generic filename with the correct extension: ext = mimetypes.guess_extension(attachment.mimetype, strict=False) if ext is not None: attachment_object["filename"] = f"attachment{ext}" if attachment.inline: attachment_object["disposition"] = "inline" attachment_object["id"] = attachment.cid self.data.setdefault("attachments", []).append(attachment_object) # MailerSend doesn't have metadata # def set_metadata(self, metadata): def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; MailerSend expects unix timestamp self.data["send_at"] = int(send_at.timestamp()) # strip microseconds def set_tags(self, tags): if tags: self.data["tags"] = tags def set_track_clicks(self, track_clicks): self.data.setdefault("settings", {})["track_clicks"] = track_clicks def set_track_opens(self, track_opens): self.data.setdefault("settings", {})["track_opens"] = track_opens def set_template_id(self, template_id): self.data["template_id"] = template_id 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): # late bound in serialize_data self.merge_global_data = merge_global_data # MailerSend doesn't have metadata # def set_merge_metadata(self, merge_metadata): def set_esp_extra(self, extra): # Deep merge to allow (e.g.,) {"settings": {"track_content": True}}: update_deep(self.data, extra) # Allow overriding api_token on individual message: try: api_token = self.data.pop("api_token") except KeyError: pass else: self.headers["Authorization"] = f"Bearer {api_token}" # Allow overriding batch_send_mode on individual message: try: self.batch_send_mode = self.data.pop("batch_send_mode") except KeyError: pass