Skip to content

Commit

Permalink
Set up basic SendGrid Handler (#73)
Browse files Browse the repository at this point in the history
* feat: sendgrid handler function

* feat: email handler

* feat: unit tests for sendgrid

* fix: sendgrid tests

* feat: aiosendgrid and sendgrid stubs

* fix: PersonalizationData type fix

* fix: type errors in tests

* fix: new TypedDict format for PersonalizationData

* fix: formatting

* fix: move PersonalizationData to sendgrid_handler

* Add typing overloads for SendGrid template data

- Move `Template` enum from email_handler to sendgrid_handler
- Include overloads to associate each SendGrid template with specific
  personalization data
- Use overloads for `send_to_multiple` parameter which affects shape of
  provided `receiver_data`

* update: README.md

* update: sendgrid template IDs

* update: template IDs

---------

Co-authored-by: Taesung Hwang <[email protected]>
  • Loading branch information
samderanova and taesungh authored Dec 15, 2023
1 parent 9f953c3 commit c48c987
Show file tree
Hide file tree
Showing 12 changed files with 291 additions and 0 deletions.
1 change: 1 addition & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ For deployment, the following environment variables need to be set:

- `PYTHONPATH=src/api` to properly import Python modules
- `SP_KEY`, the private key for SAML authentication
- `SENDGRID_API_KEY`, the API key needed to use the SendGrid API

For staging, the following environment variables should also bet set:

Expand Down
2 changes: 2 additions & 0 deletions apps/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ python-multipart==0.0.5
python3-saml==1.16.0
motor==3.3.2
pydantic[email]==2.5.2
aiosendgrid==0.1.0
sendgrid==6.11.0
109 changes: 109 additions & 0 deletions apps/api/src/services/sendgrid_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# using SendGrid's Python Library
# https://github.com/sendgrid/sendgrid-python
import os
from enum import Enum
from logging import getLogger
from typing import Iterable, Literal, Tuple, TypedDict, Union, overload

import aiosendgrid
from httpx import HTTPStatusError
from sendgrid.helpers.mail import Email, Mail, Personalization

log = getLogger(__name__)

SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")


class Template(str, Enum):
CONFIRMATION_EMAIL = "d-83d42cc17b54456183eeb946ba58861a"
GUEST_TOKEN = "d-1998e588ddf74c6d9ede36b778730176"


class PersonalizationData(TypedDict):
email: str


class ConfirmationPersonalization(PersonalizationData):
first_name: str
last_name: str


class GuestTokenPersonalization(PersonalizationData):
passphrase: str


@overload
async def send_email(
template_id: Literal[Template.CONFIRMATION_EMAIL],
sender_email: Tuple[str, str],
receiver_data: ConfirmationPersonalization,
send_to_multiple: Literal[False] = False,
) -> None:
...


@overload
async def send_email(
template_id: Literal[Template.GUEST_TOKEN],
sender_email: Tuple[str, str],
receiver_data: GuestTokenPersonalization,
send_to_multiple: Literal[False] = False,
) -> None:
...


@overload
async def send_email(
template_id: Literal[Template.CONFIRMATION_EMAIL],
sender_email: Tuple[str, str],
receiver_data: Iterable[ConfirmationPersonalization],
send_to_multiple: Literal[True],
) -> None:
...


async def send_email(
template_id: Template,
sender_email: Tuple[str, str],
receiver_data: Union[PersonalizationData, Iterable[PersonalizationData]],
send_to_multiple: bool = False,
) -> None:
"""
Send a personalized templated email to one or multiple receivers via SendGrid
"""
try:
email_message = Mail()

if send_to_multiple:
if isinstance(receiver_data, dict):
raise TypeError(
f"Expected {list} for receiver_data but got {type(receiver_data)}"
)
for r in receiver_data:
p = Personalization()
p.add_to(Email(email=r["email"], dynamic_template_data=r))
email_message.add_personalization(p)
else:
if not isinstance(receiver_data, dict):
raise TypeError(
f"Expected {dict} for receiver_data but got {type(receiver_data)}"
)
p = Personalization()
p.add_to(
Email(
email=receiver_data["email"],
dynamic_template_data=receiver_data,
)
)
email_message.add_personalization(p)

email_message.from_email = sender_email
email_message.template_id = template_id

async with aiosendgrid.AsyncSendGridClient(api_key=SENDGRID_API_KEY) as client:
response = await client.send_mail_v3(body=email_message.get())
log.debug(response.status_code)
log.debug(response.headers)
except HTTPStatusError as e:
log.exception("During SendGrid processing: %s", e)
raise RuntimeError("Could not send email with SendGrid")
40 changes: 40 additions & 0 deletions apps/api/src/utils/email_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Protocol

from pydantic import EmailStr

from services import sendgrid_handler
from services.sendgrid_handler import Template

IH_SENDER = ("[email protected]", "IrvineHacks 2024 Applications")


class ContactInfo(Protocol):
email: EmailStr
first_name: str
last_name: str


async def send_application_confirmation_email(user: ContactInfo) -> None:
"""Send a confirmation email after a user submits an application.
Will propagate exceptions from SendGrid."""
await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL,
IH_SENDER,
{
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
},
)


async def send_guest_login_email(email: EmailStr, passphrase: str) -> None:
"""Email login passphrase to guest."""
await sendgrid_handler.send_email(
Template.GUEST_TOKEN,
IH_SENDER,
{
"email": email,
"passphrase": passphrase,
},
)
1 change: 1 addition & 0 deletions apps/api/stubs/aiosendgrid/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .client import AsyncSendGridClient as AsyncSendGridClient
6 changes: 6 additions & 0 deletions apps/api/stubs/aiosendgrid/client.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from httpx import AsyncClient
from httpx._models import Response as Response

class AsyncSendGridClient(AsyncClient):
def __init__(self, api_key: str | None = ...) -> None: ...
async def send_mail_v3(self, body: dict[str, object]) -> Response: ...
4 changes: 4 additions & 0 deletions apps/api/stubs/sendgrid/helpers/mail/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .email import Email as Email
from .mail import Mail as Mail
from .personalization import Personalization as Personalization
from .dynamic_template_data import DynamicTemplateData as DynamicTemplateData
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from typing import Mapping

class DynamicTemplateData:
def __init__(
self, dynamic_template_data: Mapping[str, object] | None = ..., p: int = ...
) -> None: ...
8 changes: 8 additions & 0 deletions apps/api/stubs/sendgrid/helpers/mail/email.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import Mapping

class Email:
def __init__(
self,
email: str | None = ...,
dynamic_template_data: Mapping[str, object] | None = ...,
) -> None: ...
15 changes: 15 additions & 0 deletions apps/api/stubs/sendgrid/helpers/mail/mail.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import Tuple
from .personalization import Personalization as Personalization

class Mail:
def __init__(self) -> None: ...
def add_personalization(self, personalization: Personalization) -> None: ...
@property
def from_email(self) -> Tuple[str, str]: ...
@from_email.setter
def from_email(self, value: Tuple[str, str]) -> None: ...
@property
def template_id(self) -> str: ...
@template_id.setter
def template_id(self, value: str) -> None: ...
def get(self) -> dict[str, object]: ...
5 changes: 5 additions & 0 deletions apps/api/stubs/sendgrid/helpers/mail/personalization.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .email import Email

class Personalization:
def __init__(self) -> None: ...
def add_to(self, email: Email) -> None: ...
94 changes: 94 additions & 0 deletions apps/api/tests/test_sendgrid_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from unittest.mock import AsyncMock, patch

import pytest
from aiosendgrid import AsyncSendGridClient
from httpx import HTTPStatusError, Request, Response

from services import sendgrid_handler
from services.sendgrid_handler import ConfirmationPersonalization, Template

SAMPLE_SENDER = ("[email protected]", "No Reply IrvineHacks")
SAMPLE_RECIPIENTS: list[ConfirmationPersonalization] = [
{
"email": "[email protected]",
"first_name": "Hacker",
"last_name": "Zero",
},
{
"email": "[email protected]",
"first_name": "Hacker",
"last_name": "One",
},
]


@patch("aiosendgrid.AsyncSendGridClient")
async def test_send_single_email(mock_AsyncClient: AsyncMock) -> None:
"""Tests that sending a single email calls the AsyncClient as expected"""
mock_client = AsyncMock(AsyncSendGridClient)
mock_client.send_mail_v3.return_value = Response(202)
mock_AsyncClient.return_value.__aenter__.return_value = mock_client

recipient_data = SAMPLE_RECIPIENTS[0]

await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, recipient_data
)
mock_client.send_mail_v3.assert_awaited_once_with(
body={
"from": {"name": SAMPLE_SENDER[1], "email": SAMPLE_SENDER[0]},
"personalizations": [
{
"to": [{"email": recipient_data["email"]}],
"dynamic_template_data": recipient_data,
}
],
"template_id": Template.CONFIRMATION_EMAIL,
}
)


@patch("aiosendgrid.AsyncSendGridClient")
async def test_send_multiple_emails(mock_AsyncClient: AsyncMock) -> None:
"""Tests that sending multiple emails calls the AsyncClient as expected"""
mock_client = AsyncMock(AsyncSendGridClient)
mock_client.send_mail_v3.return_value = Response(202)
mock_AsyncClient.return_value.__aenter__.return_value = mock_client

await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, SAMPLE_RECIPIENTS, True
)
mock_client.send_mail_v3.assert_awaited_once_with(
body={
"from": {"name": SAMPLE_SENDER[1], "email": SAMPLE_SENDER[0]},
"personalizations": [
{
"to": [{"email": SAMPLE_RECIPIENTS[1]["email"]}],
"dynamic_template_data": SAMPLE_RECIPIENTS[1],
},
{
"to": [{"email": SAMPLE_RECIPIENTS[0]["email"]}],
"dynamic_template_data": SAMPLE_RECIPIENTS[0],
},
],
"template_id": Template.CONFIRMATION_EMAIL,
}
)


@patch("aiosendgrid.AsyncSendGridClient")
async def test_sendgrid_error_causes_runtime_error(mock_AsyncClient: AsyncMock) -> None:
"""Test that an issue with SendGrid causes a RuntimeError"""
mock_client = AsyncMock(AsyncSendGridClient)
mock_client.send_mail_v3.side_effect = HTTPStatusError(
"SendGrid error",
request=Request("POST", "/v3/mail/send"),
response=Response(500),
)

mock_AsyncClient.return_value.__aenter__.return_value = mock_client

with pytest.raises(RuntimeError):
await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, SAMPLE_RECIPIENTS, True
)

0 comments on commit c48c987

Please sign in to comment.