From 703c09e68e3dd08ae0ffdda5d80f0e1d0c8c51a5 Mon Sep 17 00:00:00 2001 From: Taesung Hwang Date: Thu, 14 Dec 2023 15:39:07 -0800 Subject: [PATCH] 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` --- apps/api/src/services/sendgrid_handler.py | 80 +++++++++++++++++------ apps/api/src/utils/email_handler.py | 37 +++++------ apps/api/tests/test_sendgrid_handler.py | 16 +++-- 3 files changed, 87 insertions(+), 46 deletions(-) diff --git a/apps/api/src/services/sendgrid_handler.py b/apps/api/src/services/sendgrid_handler.py index 13193dd4..67efc6f8 100644 --- a/apps/api/src/services/sendgrid_handler.py +++ b/apps/api/src/services/sendgrid_handler.py @@ -1,9 +1,9 @@ # 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, Tuple, TypedDict, Union -from typing_extensions import NotRequired +from typing import Iterable, Literal, Tuple, TypedDict, Union, overload import aiosendgrid from httpx import HTTPStatusError @@ -14,15 +14,57 @@ SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY") +class Template(str, Enum): + # TODO: provide actual template IDs + CONFIRMATION_EMAIL = "d-2026cde7bebd45ad85723443808c5817" + GUEST_TOKEN = "d-b19f08e584cb4c0f97b55f567ee10afc" + + class PersonalizationData(TypedDict): email: str - first_name: NotRequired[str] - last_name: NotRequired[str] - passphrase: NotRequired[str] +class ConfirmationPersonalization(PersonalizationData): + first_name: str + last_name: str + + +class GuestTokenPersonalization(PersonalizationData): + passphrase: str + + +@overload async def send_email( - template_id: str, + 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, @@ -34,29 +76,27 @@ async def send_email( email_message = Mail() if send_to_multiple: - if not isinstance(receiver_data, list): + if isinstance(receiver_data, dict): raise TypeError( f"Expected {list} for receiver_data but got {type(receiver_data)}" ) - else: - for r in receiver_data: - p = Personalization() - p.add_to(Email(email=r["email"], dynamic_template_data=r)) - email_message.add_personalization(p) + 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)}" ) - else: - p = Personalization() - p.add_to( - Email( - email=receiver_data["email"], - dynamic_template_data=receiver_data, - ) + p = Personalization() + p.add_to( + Email( + email=receiver_data["email"], + dynamic_template_data=receiver_data, ) - email_message.add_personalization(p) + ) + email_message.add_personalization(p) email_message.from_email = sender_email email_message.template_id = template_id diff --git a/apps/api/src/utils/email_handler.py b/apps/api/src/utils/email_handler.py index 8eb36d87..7425a9d7 100644 --- a/apps/api/src/utils/email_handler.py +++ b/apps/api/src/utils/email_handler.py @@ -1,10 +1,9 @@ -from enum import Enum from typing import Protocol from pydantic import EmailStr from services import sendgrid_handler -from services.sendgrid_handler import PersonalizationData +from services.sendgrid_handler import Template IH_SENDER = ("apply@irvinehacks.com", "IrvineHacks 2024 Applications") @@ -15,27 +14,27 @@ class ContactInfo(Protocol): last_name: str -class Template(str, Enum): - # TODO: provide actual template IDs - CONFIRMATION_EMAIL = "d-2026cde7bebd45ad85723443808c5817" - GUEST_TOKEN = "d-b19f08e584cb4c0f97b55f567ee10afc" - - 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, - } - await sendgrid_handler.send_email(Template.CONFIRMATION_EMAIL, IH_SENDER, send_data) + 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.""" - send_data: PersonalizationData = { - "email": email, - "passphrase": passphrase, - } - await sendgrid_handler.send_email(Template.GUEST_TOKEN, IH_SENDER, send_data) + await sendgrid_handler.send_email( + Template.GUEST_TOKEN, + IH_SENDER, + { + "email": email, + "passphrase": passphrase, + }, + ) diff --git a/apps/api/tests/test_sendgrid_handler.py b/apps/api/tests/test_sendgrid_handler.py index fd77bd32..8308e3f7 100644 --- a/apps/api/tests/test_sendgrid_handler.py +++ b/apps/api/tests/test_sendgrid_handler.py @@ -5,10 +5,10 @@ from httpx import HTTPStatusError, Request, Response from services import sendgrid_handler -from services.sendgrid_handler import PersonalizationData +from services.sendgrid_handler import ConfirmationPersonalization, Template SAMPLE_SENDER = ("noreply@irvinehacks.com", "No Reply IrvineHacks") -SAMPLE_RECIPIENTS: list[PersonalizationData] = [ +SAMPLE_RECIPIENTS: list[ConfirmationPersonalization] = [ { "email": "hacker0@uci.edu", "first_name": "Hacker", @@ -31,7 +31,9 @@ async def test_send_single_email(mock_AsyncClient: AsyncMock) -> None: recipient_data = SAMPLE_RECIPIENTS[0] - await sendgrid_handler.send_email("my-template-id", SAMPLE_SENDER, recipient_data) + 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]}, @@ -41,7 +43,7 @@ async def test_send_single_email(mock_AsyncClient: AsyncMock) -> None: "dynamic_template_data": recipient_data, } ], - "template_id": "my-template-id", + "template_id": Template.CONFIRMATION_EMAIL, } ) @@ -54,7 +56,7 @@ async def test_send_multiple_emails(mock_AsyncClient: AsyncMock) -> None: mock_AsyncClient.return_value.__aenter__.return_value = mock_client await sendgrid_handler.send_email( - "my-template-id", SAMPLE_SENDER, SAMPLE_RECIPIENTS, True + Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, SAMPLE_RECIPIENTS, True ) mock_client.send_mail_v3.assert_awaited_once_with( body={ @@ -69,7 +71,7 @@ async def test_send_multiple_emails(mock_AsyncClient: AsyncMock) -> None: "dynamic_template_data": SAMPLE_RECIPIENTS[0], }, ], - "template_id": "my-template-id", + "template_id": Template.CONFIRMATION_EMAIL, } ) @@ -88,5 +90,5 @@ async def test_sendgrid_error_causes_runtime_error(mock_AsyncClient: AsyncMock) with pytest.raises(RuntimeError): await sendgrid_handler.send_email( - "my-template-id", SAMPLE_SENDER, SAMPLE_RECIPIENTS, True + Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, SAMPLE_RECIPIENTS, True )