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 6 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
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
72 changes: 72 additions & 0 deletions apps/api/src/services/sendgrid_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# using SendGrid's Python Library
# https://github.com/sendgrid/sendgrid-python
import os
from logging import getLogger
from typing import Iterable, Optional, Tuple, TypedDict, Union

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")

PersonalizationData = TypedDict(
"PersonalizationData",
{
"email": str,
"first_name": Optional[str],
"last_name": Optional[str],
"passphrase": Optional[str],
},
)
samderanova marked this conversation as resolved.
Show resolved Hide resolved


async def send_email(
template_id: str,
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 not isinstance(receiver_data, list):
raise TypeError(
f"Expected {list} for receiver_data but got {type(receiver_data)}"
)
else:
for r in receiver_data:
taesungh marked this conversation as resolved.
Show resolved Hide resolved
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)}"
)
else:
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")
44 changes: 44 additions & 0 deletions apps/api/src/utils/email_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from enum import Enum
from typing import Protocol

from pydantic import EmailStr

from services import sendgrid_handler
from services.sendgrid_handler import PersonalizationData

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


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


class Template(str, Enum):
# TODO: provide actual template IDs
CONFIRMATION_EMAIL = "d-2026cde7bebd45ad85723443808c5817"
GUEST_TOKEN = "d-b19f08e584cb4c0f97b55f567ee10afc"
samderanova marked this conversation as resolved.
Show resolved Hide resolved


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


async def send_guest_login_email(email: EmailStr, passphrase: str) -> None:
"""Email login passphrase to guest."""
send_data: PersonalizationData = {
"email": email,
"passphrase": passphrase,
"first_name": None,
"last_name": None,
}
await sendgrid_handler.send_email(Template.GUEST_TOKEN, IH_SENDER, send_data)
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: ...
83 changes: 83 additions & 0 deletions apps/api/tests/test_sendgrid_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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

SAMPLE_SENDER = ("[email protected]", "No Reply IrvineHacks")
SAMPLE_RECIPIENTS = [
{"email": "[email protected]", "name": "Hacker Zero"},
{"email": "[email protected]", "name": "Hacker 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("my-template-id", SAMPLE_SENDER, recipient_data)
samderanova marked this conversation as resolved.
Show resolved Hide resolved
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": "my-template-id",
}
)


@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(
"my-template-id", SAMPLE_SENDER, SAMPLE_RECIPIENTS, True
samderanova marked this conversation as resolved.
Show resolved Hide resolved
)
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": "my-template-id",
}
)


@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(
"my-template-id", SAMPLE_SENDER, SAMPLE_RECIPIENTS, True
samderanova marked this conversation as resolved.
Show resolved Hide resolved
)
Loading