From c912c1e37e22b531665c3ec0a92de43fc5e6961a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20D=C3=ADaz?= Date: Thu, 9 Oct 2025 16:47:17 -0300 Subject: [PATCH 1/2] feature(resend): Add resend emails provider --- mailing/README.md | 64 ++++++++++++++++++++++++++++++++++++------- mailing/constants.py | 4 +++ mailing/exceptions.py | 2 ++ mailing/mailing.py | 60 ++++++++++++++++++++++++++++++++++++++-- mailing/services.py | 48 ++++++++++++++++++++++++++++++++ requirements/base.txt | 3 +- settings.py | 1 + 7 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 mailing/constants.py create mode 100644 mailing/exceptions.py create mode 100644 mailing/services.py diff --git a/mailing/README.md b/mailing/README.md index 2c0502e..ccd0053 100644 --- a/mailing/README.md +++ b/mailing/README.md @@ -1,15 +1,16 @@ Mailing module — using mailing.mailing.Email -This module wraps SendGrid to send emails from your Python project. +This module wraps SendGrid and Resend to send emails from your Python project. Prerequisites -- Install dependencies (SendGrid is already listed in requirements): +- Install dependencies (SendGrid and Resend are already listed in requirements): pip install -r requirements/base.txt - Set required environment variables (used by settings.py): - - SENDGRID_API_KEY: Your SendGrid API key (optional if you pass api_key to Email). - - SENDGRID_FROM_EMAIL: Default sender email (must be verified in SendGrid). + - SENDGRID_API_KEY: Your SendGrid API key (optional if you pass api_key to Email for SendGrid). + - SENDGRID_FROM_EMAIL: Default sender email (used as the default "from" for both providers; must be verified in your provider). + - RESEND_API_KEY: Your Resend API key (optional if you pass api_key to Email for Resend). -Basic usage +Basic usage — SendGrid 1) Import and construct an Email instance ```python from mailing.mailing import Email @@ -17,7 +18,7 @@ from mailing.mailing import Email email = Email( subject="Hello from python-mailing", message="This is the plain-text fallback body.", - # recipient_list=["recipient@example.com"], + recipient_list=["recipient@example.com"], # Optional overrides (each parameter is optional): # from_email="no-reply@yourdomain.com", # api_key="SG.xxxxxx.yyyyyy", # overrides settings.SENDGRID_API_KEY for this Email @@ -31,18 +32,19 @@ else: print("Sending failed — see logs for details.") ``` -Notes and tips +Notes and tips (SendGrid) - recipient_list can include multiple addresses: Email(subject="...", message="...", recipient_list=["a@x.com", "b@y.com"]). - If you don’t need HTML, omit html_content and only plain text will be sent. -- You can override the default sender by passing from_email. If not provided, settings.FROM_EMAIL is used. +- You can override the default sender by passing from_email. If not provided, settings.SENDGRID_FROM_EMAIL is used. - You can override the SendGrid API key per email by passing api_key to Email(...). If not provided, settings.SENDGRID_API_KEY is used. - On exceptions, send_sendgrid_email returns None and logs the error (including SendGrid error body when available). - Make sure your SENDGRID_FROM_EMAIL and any sender domain are verified in SendGrid to avoid 403/unauthorized or 400 errors. - Ensure SENDGRID_API_KEY has Mail Send permissions. -Example with multiple recipients and custom from_email +Example with multiple recipients and custom from_email (SendGrid) ```python +from mailing.mailing import Email email = Email( subject="Weekly report", message="See the attached summary.", @@ -52,7 +54,6 @@ email = Email( email.send_sendgrid_email() ``` - Example overriding SendGrid API key per Email instance ```python from mailing.mailing import Email @@ -65,3 +66,46 @@ email = Email( ) email.send_sendgrid_email() ``` + +--- + +Basic usage — Resend +1) Import and construct an Email instance +```python +from mailing.mailing import Email + +email = Email( + subject="Hello from python-mailing (Resend)", + message="This is the plain-text fallback body.", + recipient_list=["recipient@example.com"], + # Optional overrides (each parameter is optional): + # from_email="no-reply@yourdomain.com", # must be a verified domain/address in Resend + # api_key="re_abc123...", # overrides settings.RESEND_API_KEY for this Email + html_content="

This is the HTML body.

", +) +# Send with Resend +response = email.send_resend_email() +if response is not None: + print("Sent via Resend!", response) +else: + print("Sending via Resend failed — see logs for details.") +``` + +Notes and tips (Resend) +- The default from_email comes from settings.SENDGRID_FROM_EMAIL in this template. You can override it per email by passing from_email. Ensure the address/domain is verified in Resend. +- You can override the Resend API key per email by passing api_key to Email(...). If not provided, settings.RESEND_API_KEY is used. +- send_resend_email returns the provider response (as returned by resend.Emails.send) or None on failure. The response is also logged at info level. +- recipient_list can include multiple addresses, same as with SendGrid. + +Example overriding Resend API key per Email instance +```python +from mailing.mailing import Email + +email = Email( + subject="On-demand API key (Resend)", + message="Body", + recipient_list=["user@example.com"], + api_key="re_abc123...", # this value will be used instead of settings.RESEND_API_KEY +) +email.send_resend_email() +``` diff --git a/mailing/constants.py b/mailing/constants.py new file mode 100644 index 0000000..3514da1 --- /dev/null +++ b/mailing/constants.py @@ -0,0 +1,4 @@ +PROVIDER_SENDGRID = "sendgrid" +PROVIDER_RESEND = "resend" + +ERROR_PROVIDER_NOT_CONFIGURED = "Provider not specified or not configured." diff --git a/mailing/exceptions.py b/mailing/exceptions.py new file mode 100644 index 0000000..ab7626b --- /dev/null +++ b/mailing/exceptions.py @@ -0,0 +1,2 @@ +class ProviderNotConfigured(Exception): + pass diff --git a/mailing/mailing.py b/mailing/mailing.py index 5d9884c..f80b7be 100644 --- a/mailing/mailing.py +++ b/mailing/mailing.py @@ -1,8 +1,16 @@ -"""Send emails.""" +"""Utilities to send transactional emails via SendGrid and Resend. + +This module exposes a small Email helper that can deliver messages using +- SendGrid (HTTP API via sendgrid-python) +- Resend (HTTP API via resend) + +It intentionally keeps provider-specific details inside dedicated methods. +""" import logging from typing import Any +import resend from sendgrid import SendGridAPIClient, Mail import settings @@ -11,6 +19,13 @@ class Email: + """Simple wrapper to send email messages via SendGrid or Resend. + + Instantiate with the desired subject, message, recipients, and optional + HTML content. Use `send_sendgrid_email` or `send_resend_email` to actually + deliver the email via the chosen provider. + """ + def __init__( self, subject: str, @@ -21,7 +36,21 @@ def __init__( api_key: str = None, **kwargs: Any, ): - """Initialize the Email class.""" + """Create an Email object. + + Args: + subject: Email subject line. + message: Plain text body of the email. + recipient_list: List of recipient email addresses. + from_email: Sender address. Defaults to settings.SENDGRID_FROM_EMAIL + when not provided. + html_content: Optional HTML body. If provided, it is sent alongside + the plain text `message` where supported. + api_key: Optional API key to override the default provider key set + in settings. If omitted, provider-specific defaults from + settings are used. + **kwargs: Extra keyword arguments reserved for future extensions. + """ self.subject = subject self.message = message self.from_email = from_email or settings.SENDGRID_FROM_EMAIL @@ -30,7 +59,13 @@ def __init__( self.api_key = api_key def send_sendgrid_email(self, **kwargs: Any) -> Any | None: - """Send an email using SendGrid.""" + """Send this email using the SendGrid provider. + + Returns: + The provider response object on success, or None if an exception + occurs during the API call. Errors are logged, including the body + from SendGrid when available. + """ sg = SendGridAPIClient(self.api_key or settings.SENDGRID_API_KEY) message = Mail( from_email=self.from_email, @@ -47,3 +82,22 @@ def send_sendgrid_email(self, **kwargs: Any) -> Any | None: logger.exception("SendGrid exception") return None return response + + def send_resend_email(self, **kwargs: Any) -> Any | None: + """Send this email using the Resend provider. + + Returns: + The provider response object on success, or None on failure. + Any response content is also logged at info level. + """ + resend.api_key = self.api_key or settings.RESEND_API_KEY + params: resend.Emails.SendParams = { + "from": self.from_email, + "to": self.recipient_list, + "subject": self.subject, + "html": self.html_content, + "text": self.message, + } + email = resend.Emails.send(params) + logger.info(email) + return email diff --git a/mailing/services.py b/mailing/services.py new file mode 100644 index 0000000..22aad01 --- /dev/null +++ b/mailing/services.py @@ -0,0 +1,48 @@ +from mailing.constants import PROVIDER_SENDGRID, PROVIDER_RESEND, ERROR_PROVIDER_NOT_CONFIGURED +from mailing.exceptions import ProviderNotConfigured +from mailing.mailing import Email + + +def send_email( + provider: str, + subject: str, + message: str, + recipient_list: list[str], + from_email: str = None, + html_content: str = None, + api_key: str = None, +): + """ + Send an email using the specified provider. + + This function facilitates sending an email by delegating the sending process + to the appropriate email provider. It constructs an email object with the + provided content and metadata and routes the sending operation to the correct + provider implementation. Supported providers are SendGrid and Resend. Raises + an error if an unrecognized provider is specified. + + :param provider: The email service provider to use for sending the email. + :param subject: The subject line of the email. + :param message: The plain text content of the email body. + :param recipient_list: A list of recipients' email addresses. + :param from_email: (Optional) The sender's email address. + :param html_content: (Optional) The HTML content of the email body. + :param api_key: (Optional) The API key associated with the chosen provider. + :return: None + :raises ProviderNotConfigured: If the specified email provider is not + configured or supported. + """ + email = Email( + subject=subject, + message=message, + recipient_list=recipient_list, + from_email=from_email, + html_content=html_content, + api_key=api_key, + ) + if provider == PROVIDER_SENDGRID: + email.send_sendgrid_email() + elif provider == PROVIDER_RESEND: + email.send_resend_email() + else: + raise ProviderNotConfigured(ERROR_PROVIDER_NOT_CONFIGURED) diff --git a/requirements/base.txt b/requirements/base.txt index 2986aca..8df6d6b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,5 @@ black==25.9.0 pytest==8.4.2 pytest-mock==3.15.1 -sendgrid==6.12.5 \ No newline at end of file +sendgrid==6.12.5 +resend==2.15.0 \ No newline at end of file diff --git a/settings.py b/settings.py index 447d6e6..29adfa9 100644 --- a/settings.py +++ b/settings.py @@ -2,3 +2,4 @@ SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") SENDGRID_FROM_EMAIL = os.environ.get("SENDGRID_FROM_EMAIL") +RESEND_API_KEY = os.environ.get("RESEND_API_KEY") From f37d39c61feb68fd91a208ad1c0202d3a6a0c97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Axel=20D=C3=ADaz?= Date: Thu, 9 Oct 2025 16:51:01 -0300 Subject: [PATCH 2/2] feature(resend): Add resend tests --- mailing/tests/conftest.py | 2 + mailing/tests/test_resend.py | 81 +++++++++++++++++++++++++++++ mailing/tests/test_services.py | 95 ++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 mailing/tests/test_resend.py create mode 100644 mailing/tests/test_services.py diff --git a/mailing/tests/conftest.py b/mailing/tests/conftest.py index 124002d..0dcc471 100644 --- a/mailing/tests/conftest.py +++ b/mailing/tests/conftest.py @@ -10,6 +10,7 @@ def _env_settings(monkeypatch): """ monkeypatch.setenv("SENDGRID_API_KEY", "test-api-key") monkeypatch.setenv("SENDGRID_FROM_EMAIL", "from@example.com") + monkeypatch.setenv("RESEND_API_KEY", "re_test_api_key") # Also patch the already-imported settings module so defaults apply even if # settings was imported before this fixture ran. @@ -17,6 +18,7 @@ def _env_settings(monkeypatch): monkeypatch.setattr(_settings, "SENDGRID_API_KEY", "test-api-key", raising=False) monkeypatch.setattr(_settings, "SENDGRID_FROM_EMAIL", "from@example.com", raising=False) + monkeypatch.setattr(_settings, "RESEND_API_KEY", "re_test_api_key", raising=False) yield diff --git a/mailing/tests/test_resend.py b/mailing/tests/test_resend.py new file mode 100644 index 0000000..bb9df4e --- /dev/null +++ b/mailing/tests/test_resend.py @@ -0,0 +1,81 @@ +import types + +import pytest + +from mailing.mailing import Email + + +class TestResendMailing: + @pytest.fixture() + def mock_resend(self, monkeypatch): + """Patch mailing.mailing.resend with a lightweight dummy implementation. + + It captures the api_key set by the code and the params passed to Emails.send. + """ + import mailing.mailing as mm + + DummyResend = types.SimpleNamespace() + DummyResend.api_key = None + + class Emails: + last_params = None + + @staticmethod + def send(params): + Emails.last_params = params + return {"id": "email_123"} + + DummyResend.Emails = Emails + + monkeypatch.setattr(mm, "resend", DummyResend, raising=True) + return DummyResend + + def test_send_resend_email_success_returns_response_and_sets_api_key( + self, mock_resend, email_data + ): + # Given + email = Email(**email_data) + + # When + response = email.send_resend_email() + + # Then + assert response == {"id": "email_123"} + # api_key should come from settings (set by conftest) + assert mock_resend.api_key == "re_test_api_key" + + # Params sent to Resend should match what Email builds + sent = mock_resend.Emails.last_params + assert sent["from"] == email_data["from_email"] + assert sent["to"] == email_data["recipient_list"] + assert sent["subject"] == email_data["subject"] + assert sent["html"] == email_data["html_content"] + assert sent["text"] == email_data["message"] + + def test_send_resend_email_uses_default_from_email_when_not_provided(self, mock_resend): + # Given: no from_email passed, should use settings.SENDGRID_FROM_EMAIL from env + data = { + "subject": "Subj R", + "message": "Body R", + "recipient_list": ["t@example.com"], + "html_content": None, + } + + # When + Email(**data).send_resend_email() + + # Then + sent = mock_resend.Emails.last_params + assert sent["from"] == "from@example.com" + assert sent["text"] == "Body R" + assert sent["html"] is None + + def test_send_resend_email_overrides_api_key_when_provided(self, mock_resend, email_data): + # Given + email = Email(**{**email_data, "api_key": "re_override"}) + + # When + email.send_resend_email() + + # Then + assert mock_resend.api_key == "re_override" diff --git a/mailing/tests/test_services.py b/mailing/tests/test_services.py new file mode 100644 index 0000000..c447c43 --- /dev/null +++ b/mailing/tests/test_services.py @@ -0,0 +1,95 @@ +import pytest + +from mailing.constants import PROVIDER_RESEND, PROVIDER_SENDGRID +from mailing.exceptions import ProviderNotConfigured + + +class TestServicesSendEmail: + def test_routes_to_sendgrid(self, monkeypatch): + # Arrange: Patch services.Email with a dummy that records which method was called + import mailing.services as ms + + class DummyEmail: + last_instance = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.send_sendgrid_email_called = False + self.send_resend_email_called = False + DummyEmail.last_instance = self + + def send_sendgrid_email(self): + self.send_sendgrid_email_called = True + + def send_resend_email(self): + raise AssertionError("send_resend_email should not be called for SendGrid provider") + + monkeypatch.setattr(ms, "Email", DummyEmail, raising=True) + + # Act + ms.send_email( + provider=PROVIDER_SENDGRID, + subject="S", + message="Body", + recipient_list=["a@b.com"], + from_email="from@example.com", + html_content="

x

", + api_key="key1", + ) + + # Assert + inst = DummyEmail.last_instance + assert inst is not None + assert inst.send_sendgrid_email_called is True + # Ensure kwargs were passed through + assert inst.kwargs["subject"] == "S" + assert inst.kwargs["message"] == "Body" + assert inst.kwargs["recipient_list"] == ["a@b.com"] + assert inst.kwargs["from_email"] == "from@example.com" + assert inst.kwargs["html_content"] == "

x

" + assert inst.kwargs["api_key"] == "key1" + + def test_routes_to_resend(self, monkeypatch): + import mailing.services as ms + + class DummyEmail: + last_instance = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.send_sendgrid_email_called = False + self.send_resend_email_called = False + DummyEmail.last_instance = self + + def send_sendgrid_email(self): + raise AssertionError("send_sendgrid_email should not be called for Resend provider") + + def send_resend_email(self): + self.send_resend_email_called = True + + monkeypatch.setattr(ms, "Email", DummyEmail, raising=True) + + ms.send_email( + provider=PROVIDER_RESEND, + subject="R", + message="BodyR", + recipient_list=["r@ex.com"], + ) + + inst = DummyEmail.last_instance + assert inst is not None + assert inst.send_resend_email_called is True + assert inst.kwargs["subject"] == "R" + assert inst.kwargs["message"] == "BodyR" + assert inst.kwargs["recipient_list"] == ["r@ex.com"] + + def test_raises_for_unknown_provider(self): + import mailing.services as ms + + with pytest.raises(ProviderNotConfigured): + ms.send_email( + provider="unknown", + subject="X", + message="Y", + recipient_list=["z@example.com"], + )