Skip to content
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
64 changes: 54 additions & 10 deletions mailing/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
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

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
Expand All @@ -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.",
Expand All @@ -52,7 +54,6 @@ email = Email(
email.send_sendgrid_email()
```


Example overriding SendGrid API key per Email instance
```python
from mailing.mailing import Email
Expand All @@ -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="<p>This is the <strong>HTML</strong> body.</p>",
)
# 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()
```
4 changes: 4 additions & 0 deletions mailing/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
PROVIDER_SENDGRID = "sendgrid"
PROVIDER_RESEND = "resend"

ERROR_PROVIDER_NOT_CONFIGURED = "Provider not specified or not configured."
2 changes: 2 additions & 0 deletions mailing/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ProviderNotConfigured(Exception):
pass
60 changes: 57 additions & 3 deletions mailing/mailing.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
48 changes: 48 additions & 0 deletions mailing/services.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions mailing/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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.
import settings as _settings

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


Expand Down
81 changes: 81 additions & 0 deletions mailing/tests/test_resend.py
Original file line number Diff line number Diff line change
@@ -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"
Loading