Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up basic SendGrid Handler #73

Merged
merged 15 commits into from
Dec 15, 2023
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
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
samderanova marked this conversation as resolved.
Show resolved Hide resolved
from httpx import HTTPStatusError
from sendgrid.helpers.mail import Email, Mail, Personalization
samderanova marked this conversation as resolved.
Show resolved Hide resolved

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
samderanova marked this conversation as resolved.
Show resolved Hide resolved
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
)
Loading