diff --git a/anymail/backends/resend.py b/anymail/backends/resend.py new file mode 100644 index 00000000..f5a51965 --- /dev/null +++ b/anymail/backends/resend.py @@ -0,0 +1,151 @@ +import mimetypes + +from ..message import AnymailRecipientStatus +from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting +from .base_requests import AnymailRequestsBackend, RequestsPayload + + +class EmailBackend(AnymailRequestsBackend): + """ + Resend (resend.com) API Email Backend + """ + + esp_name = "Resend" + + 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.resend.com/", + ) + if not api_url.endswith("/"): + api_url += "/" + super().__init__(api_url, **kwargs) + + def build_message_payload(self, message, defaults): + return ResendPayload(message, defaults, self) + + def parse_recipient_status(self, response, payload, message): + # Resend provides single message id, no other information. + # Assume "queued". + parsed_response = self.deserialize_json_response(response, payload, message) + message_id = parsed_response["id"] + recipient_status = CaseInsensitiveCasePreservingDict( + { + recip.addr_spec: AnymailRecipientStatus( + message_id=message_id, status="queued" + ) + for recip in payload.recipients + } + ) + return dict(recipient_status) + + +class ResendPayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + self.recipients = [] # for parse_recipient_status + headers = kwargs.pop("headers", {}) + headers["Authorization"] = "Bearer %s" % backend.api_key + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) + + def get_api_endpoint(self): + return "emails" + + def serialize_data(self): + return self.serialize_json(self.data) + + # + # Payload construction + # + + def init_payload(self): + self.data = {} # becomes json + + def set_from_email(self, email): + self.data["from"] = email.address + + def set_recipients(self, recipient_type, emails): + assert recipient_type in ["to", "cc", "bcc"] + if emails: + field = recipient_type + self.data[field] = [email.address for email in emails] + self.recipients += emails + + def set_subject(self, subject): + self.data["subject"] = subject + + def set_reply_to(self, emails): + if emails: + self.data["reply_to"] = [email.address for email in emails] + + def set_extra_headers(self, headers): + self.data["headers"] = headers + + 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 + + @staticmethod + def make_attachment(attachment): + """Returns Resend attachment dict for attachment""" + filename = attachment.name or "" + if not filename: + # Provide default name with reasonable extension. + # (Resend guesses content type from the filename extension; + # there doesn't seem to be any other way to specify it.) + ext = mimetypes.guess_extension(attachment.content_type) + if ext is not None: + filename = f"attachment{ext}" + att = {"content": attachment.b64content, "filename": filename} + # attachment.inline / attachment.cid not supported + return att + + def set_attachments(self, attachments): + if attachments: + if any(att.content_id for att in attachments): + self.unsupported_feature("inline content-id") + self.data["attachments"] = [ + self.make_attachment(attachment) for attachment in attachments + ] + + def set_metadata(self, metadata): + # TODO: optionally use custom header + self.data["tags"] = [ + {"name": key, "value": str(value)} for key, value in metadata.items() + ] + + # Resend doesn't support delayed sending + # def set_send_at(self, send_at): + + def set_tags(self, tags): + # TODO: optionally use tag or custom header + super().set_tags(tags) + + # Resend doesn't support changing click/open tracking per message + # def set_track_clicks(self, track_clicks): + # def set_track_opens(self, track_opens): + + # Resend doesn't support server-rendered templates. + # (Their template feature is rendered client-side, + # using React in node.js.) + # def set_template_id(self, template_id): + # def set_merge_data(self, merge_data): + # def set_merge_global_data(self, merge_global_data): + # def set_merge_metadata(self, merge_metadata): + + def set_esp_extra(self, extra): + self.data.update(extra) diff --git a/tests/test_resend_backend.py b/tests/test_resend_backend.py new file mode 100644 index 00000000..18841e0c --- /dev/null +++ b/tests/test_resend_backend.py @@ -0,0 +1,485 @@ +from base64 import b64encode +from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage +from unittest import skip + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import ( + AnymailAPIError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +from anymail.message import attach_inline_image_file + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) + + +@tag("resend") +@override_settings( + EMAIL_BACKEND="anymail.backends.resend.EmailBackend", + ANYMAIL={ + "RESEND_API_KEY": "test_api_key", + }, +) +class ResendBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = b'{"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}' + + def setUp(self): + super().setUp() + # Simple message useful for many tests + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) + + +@tag("resend") +class ResendBackendStandardEmailTests(ResendBackendMockAPITestCase): + """Test backend support for Django standard email features""" + + def test_send_mail(self): + """Test basic API for simple send""" + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("/emails") + headers = self.get_api_call_headers() + self.assertEqual(headers["Authorization"], "Bearer test_api_key") + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["text"], "Here is the message.") + self.assertEqual(data["from"], "from@sender.example.com") + self.assertEqual(data["to"], ["to@example.com"]) + + def test_name_addr(self): + """Make sure RFC2822 name-addr format (with display-name) is allowed + + (Test both sender and recipient addresses) + """ + msg = mail.EmailMessage( + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) + msg.send() + data = self.get_api_call_json() + self.assertEqual(data["from"], "From Name ") + self.assertEqual( + data["to"], ["Recipient #1 ", "to2@example.com"] + ) + self.assertEqual( + data["cc"], ["Carbon Copy ", "cc2@example.com"] + ) + self.assertEqual( + data["bcc"], ["Blind Copy ", "bcc2@example.com"] + ) + + def test_email_message(self): + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + reply_to=["another@example.com"], + headers={ + "X-MyHeader": "my value", + }, + ) + email.send() + data = self.get_api_call_json() + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["text"], "Body goes here") + self.assertEqual(data["from"], "from@example.com") + self.assertEqual(data["to"], ["to1@example.com", "Also To "]) + self.assertEqual( + data["bcc"], ["bcc1@example.com", "Also BCC "] + ) + self.assertEqual(data["cc"], ["cc1@example.com", "Also CC "]) + self.assertEqual(data["reply_to"], ["another@example.com"]) + self.assertCountEqual( + data["headers"], + {"X-MyHeader": "my value"}, + ) + + def test_html_message(self): + text_content = "This is an important message." + html_content = "

This is an important message.

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

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) + email.content_subtype = "html" # Main content is now text/html + email.send() + data = self.get_api_call_json() + self.assertNotIn("text", data) + self.assertEqual(data["html"], html_content) + + def test_extra_headers(self): + self.message.extra_headers = {"X-Custom": "string", "X-Num": 123} + self.message.send() + data = self.get_api_call_json() + self.assertCountEqual(data["headers"], {"X-Custom": "string", "X-Num": "123"}) + + def test_extra_headers_serialization_error(self): + self.message.extra_headers = {"X-Custom": Decimal(12.5)} + with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): + self.message.send() + + def test_reply_to(self): + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + ) + email.send() + data = self.get_api_call_json() + self.assertEqual( + data["reply_to"], ["reply@example.com", "Other "] + ) + + def test_attachments(self): + text_content = "* Item one\n* Item two\n* Item three" + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) + + # Should guess mimetype if not provided... + png_content = b"PNG\xb4 pretend this is the contents of a png file" + self.message.attach(filename="test.png", content=png_content) + + # Should work with a MIMEBase object (also tests no filename)... + pdf_content = b"PDF\xb4 pretend this is valid pdf data" + mimeattachment = MIMEBase("application", "pdf") + mimeattachment.set_payload(pdf_content) + self.message.attach(mimeattachment) + + self.message.send() + data = self.get_api_call_json() + attachments = data["attachments"] + self.assertEqual(len(attachments), 3) + self.assertEqual(attachments[0]["filename"], "test.txt") + self.assertEqual( + decode_att(attachments[0]["content"]).decode("ascii"), text_content + ) + + self.assertEqual(attachments[1]["filename"], "test.png") + self.assertEqual(decode_att(attachments[1]["content"]), png_content) + + # unnamed attachment given default name with correct extension for content type + self.assertEqual(attachments[2]["filename"], "attachment.pdf") + self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) + + def test_unicode_attachment_correctly_decoded(self): + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "filename": "Une pièce jointe.html", + "content": b64encode("

\u2019

".encode("utf-8")).decode( + "ascii" + ), + } + ], + ) + + def test_embedded_images(self): + # Resend's API doesn't have a way to specify content-id + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + + cid = attach_inline_image_file(self.message, image_path) # Read from a png file + html_content = ( + '

This has an inline image.

' % cid + ) + self.message.attach_alternative(html_content, "text/html") + + with self.assertRaisesMessage(AnymailUnsupportedFeature, "inline content-id"): + self.message.send() + + def test_attached_images(self): + image_filename = SAMPLE_IMAGE_FILENAME + image_path = sample_image_path(image_filename) + image_data = sample_image_content(image_filename) + + # option 1: attach as a file + self.message.attach_file(image_path) + + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) + self.message.attach(image) + + image_data_b64 = b64encode(image_data).decode("ascii") + + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["attachments"], + [ + { + "filename": image_filename, # the named one + "content": image_data_b64, + }, + { + # For unnamed attachments, Anymail constructs a default name + # based on the content_type: + "filename": "attachment.png", + "content": image_data_b64, + }, + ], + ) + + def test_multiple_html_alternatives(self): + # Multiple alternatives not allowed + self.message.attach_alternative("

First html is OK

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

But not second html

", "text/html") + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple html parts"): + self.message.send() + + def test_html_alternative(self): + # Only html alternatives allowed + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_alternatives_fail_silently(self): + # Make sure fail_silently is respected + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + sent = self.message.send(fail_silently=True) + self.assert_esp_not_called("API should not be called when send fails silently") + self.assertEqual(sent, 0) + + def test_suppress_empty_address_lists(self): + """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("cc", data) + self.assertNotIn("bcc", data) + self.assertNotIn("reply_to", data) + + # Test empty `to`--but send requires at least one recipient somewhere (like cc) + self.message.to = [] + self.message.cc = ["cc@example.com"] + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("to", data) + + def test_api_failure(self): + failure_response = { + "statusCode": 400, + "message": "API key is invalid", + "name": "validation_error", + } + self.set_mock_response(status_code=400, json_data=failure_response) + with self.assertRaisesMessage( + AnymailAPIError, r"Resend API response 400" + ) as cm: + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) + self.assertIn("API key is invalid", str(cm.exception)) + + # Make sure fail_silently is respected + self.set_mock_response(status_code=422, json_data=failure_response) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) + self.assertEqual(sent, 0) + + +@tag("resend") +class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase): + """Test backend support for Anymail added features""" + + def test_envelope_sender(self): + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): + self.message.send() + + def test_metadata(self): + self.message.metadata = {"user_id": "12345", "items": 6} + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["tags"], + [{"name": "user_id", "value": "12345"}, {"name": "items", "value": "6"}], + ) + + def test_send_at(self): + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): + self.message.send() + + def test_tags(self): + self.message.tags = ["receipt"] + with self.assertRaisesMessage(AnymailUnsupportedFeature, "tags"): + self.message.send() + + def test_track_opens(self): + self.message.track_opens = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): + self.message.send() + + def test_track_clicks(self): + self.message.track_clicks = True + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): + self.message.send() + + def test_default_omits_options(self): + """Make sure by default we don't send any ESP-specific options. + + Options not specified by the caller should be omitted entirely from + the API call (*not* sent as False or empty). This ensures + that your ESP account settings apply by default. + """ + self.message.send() + data = self.get_api_call_json() + self.assertNotIn("headers", data) + self.assertNotIn("attachments", data) + self.assertNotIn("tags", data) + + def test_esp_extra(self): + self.message.esp_extra = { + "future_resend_option": "some-value", + } + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["future_resend_option"], "some-value") + + # noinspection PyUnresolvedReferences + def test_send_attaches_anymail_status(self): + """The anymail_status should be attached to the message when it is sent""" + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["Recipient "], + ) + sent = msg.send() + self.assertEqual(sent, 1) + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual( + msg.anymail_status.message_id, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) + + # noinspection PyUnresolvedReferences + def test_send_failed_anymail_status(self): + """If the send fails, anymail_status should contain initial values""" + self.set_mock_response(status_code=500) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertIsNone(self.message.anymail_status.esp_response) + + # noinspection PyUnresolvedReferences + def test_send_unparsable_response(self): + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) + with self.assertRaises(AnymailAPIError): + self.message.send() + self.assertIsNone(self.message.anymail_status.status) + self.assertIsNone(self.message.anymail_status.message_id) + self.assertEqual(self.message.anymail_status.recipients, {}) + self.assertEqual(self.message.anymail_status.esp_response, mock_response) + + @skip("TODO") # TODO: for X-Metadata header + def test_json_serialization_errors(self): + """Try to provide more information about non-json-serializable data""" + self.message.metadata = {"price": Decimal("19.99")} # yeah, don't do this + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + print(self.get_api_call_json()) + 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 Resend", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") + + +@tag("resend") +class ResendBackendRecipientsRefusedTests(ResendBackendMockAPITestCase): + # Resend 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 + + +@tag("resend") +class ResendBackendSessionSharingTestCase( + SessionSharingTestCases, ResendBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("resend") +@override_settings(EMAIL_BACKEND="anymail.backends.resend.EmailBackend") +class ResendBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): + """Test ESP backend without required settings in place""" + + def test_missing_api_key(self): + with self.assertRaises(ImproperlyConfigured) as cm: + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) + errmsg = str(cm.exception) + self.assertRegex(errmsg, r"\bRESEND_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_RESEND_API_KEY\b")