-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
9f953c3
commit c48c987
Showing
12 changed files
with
291 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .client import AsyncSendGridClient as AsyncSendGridClient |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
6 changes: 6 additions & 0 deletions
6
apps/api/stubs/sendgrid/helpers/mail/dynamic_template_data.pyi
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |