Skip to content

Commit 9808930

Browse files
authored
Email SeS implementation (#19)
## Notion ticket link <!-- Please replace with your ticket's URL --> [Setup Email System](https://www.notion.so/uwblueprintexecs/Setup-Basic-Email-System-11110f3fb1dc80a48e88ce63228b926c?pvs=4) NOTE THIS IS A PR for feedback not to merge. Will be removing email.py route as its simply there right now for testing will be removing todos and adding comments will be removing any prints or updating them to logs <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * Implemented Email sending using amazon ses. * Create email templates at startup * use email templates to send single and bulk emails to users with predefined templates * Email templates can use inline CSS need to define a text file with only text for each respective email Process to create a new Email Template to use: 1. Create the HTML and Text files for the new template. Use inline CSS and direct hosted links to any images required. 2. Update `backend/app/utilities/ses/ses_templates.json` to include template name subject and absolute paths to the above file 3. Create a new data class Type for the Email template ```python @DataClass class NewEmailTemplatData(): name: str date: str random: int ``` 4. Update Email template enum to include the file name of the template defined in amazon ses and the `ses_templates.json` file ```python class EmailTemplate(Enum): NEWEMAILTEMPLATE = "newemailtemplate" ``` 5. Use in code to send emails using email service ```python email_service.send_email( template=EmailTemplate.NEWEMAILTEMPLATE, content=EmailContent[NewEmailTemplatData]( recipient=recipient, data=NewEmailTemplatData(name=user_name, date="2021-12-01", random: 20) ), ) ``` ## Todo - [ ] Add bulk send email after general flow is good - [ ] Look into schedule send - [ ] Clean up logging and comments - [ ] Remove email.py route - [ ] Unit Testing - [ ] File clean up <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. Simply hit the `/email/send-test` email route when running the be on local <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * Focus on overall design of things and how things are laid out as this pr still needs to be cleaned up. * How templates are defined, what folders code blocks should be stored in * Are we okay with running the email template check on startup * Will be removing email.py route as its simply there right now for testing ## Checklist - [x] My PR name is descriptive and in imperative tense - [x] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [x] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR
1 parent 6c06f16 commit 9808930

File tree

18 files changed

+461
-115
lines changed

18 files changed

+461
-115
lines changed

.env.sample

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ POSTGRES_DATABASE=
22
POSTGRES_USER=
33
POSTGRES_PASSWORD=
44
POSTGRES_DATABASE_URL=
5-
5+
AWS_ACCESS_KEY_ID=
6+
AWS_SECRET_ACCESS_KEY=
7+
AWS_REGION=
8+
SES_EMAIL_FROM=
Lines changed: 17 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from abc import ABC, abstractmethod
22

3+
from app.schemas.email_template import EmailContent, EmailTemplateType
4+
35

46
class IEmailService(ABC):
57
"""
@@ -8,65 +10,20 @@ class IEmailService(ABC):
810
"""
911

1012
@abstractmethod
11-
def send_email(self, to: str, subject: str, body: str) -> dict:
12-
"""
13-
Sends an email with the given parameters.
14-
15-
:param to: Recipient's email address
16-
:type to: str
17-
:param subject: Subject of the email
18-
:type subject: str
19-
:param body: HTML body content of the email
20-
:type body: str
21-
:return: Provider-specific metadata (like message ID, thread ID, label IDs)
22-
:rtype: dict
23-
:raises Exception: if email was not sent successfully
24-
"""
25-
pass
26-
27-
@abstractmethod
28-
def send_welcome_email(self, recipient: str, user_name: str) -> dict:
29-
"""
30-
Sends a welcome email to the specified user.
31-
32-
:param recipient: Email address of the user
33-
:type recipient: str
34-
:param user_name: Name of the user
35-
:type user_name: str
36-
:return: Provider-specific metadata for the sent email
37-
:rtype: dict
38-
:raises Exception: if email was not sent successfully
39-
"""
40-
pass
41-
42-
@abstractmethod
43-
def send_password_reset_email(self, recipient: str, reset_link: str) -> dict:
44-
"""
45-
Sends a password reset email with the provided reset link.
46-
47-
:param recipient: Email address of the user requesting the reset
48-
:type recipient: str
49-
:param reset_link: Password reset link
50-
:type reset_link: str
51-
:return: Provider-specific metadata for the sent email
52-
:rtype: dict
53-
:raises Exception: if email was not sent successfully
54-
"""
55-
pass
56-
57-
@abstractmethod
58-
def send_notification_email(self, recipient: str, notification_text: str) -> dict:
59-
"""
60-
Sends a notification email to the user with the provided notification text.
61-
Examples of use case include matches completed and ready to view, new messages,
62-
meeting time scheduled, etc.
63-
64-
:param recipient: Email address of the user
65-
:type recipient: str
66-
:param notification_text: The notification content
67-
:type notification_text: str
68-
:return: Provider-specific metadata for the sent email
69-
:rtype: dict
70-
:raises Exception: if email was not sent successfully
13+
def send_email(
14+
self, templateType: EmailTemplateType, content: EmailContent
15+
) -> dict:
16+
"""Send an email using the given template and content with a
17+
respective service provider.
18+
19+
Args:
20+
templateType (EmailTemplateType): Specifies the template
21+
to be used for the email
22+
content (EmailContent): Contains the recipient and data
23+
to be used in the email
24+
25+
Returns:
26+
dict: Provider-specific metadata if any
27+
(like message ID, thread ID, label IDs)
7128
"""
7229
pass
Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from abc import ABC, abstractmethod
22

3+
from app.schemas.email_template import EmailContent, EmailTemplateType
4+
35

46
class IEmailServiceProvider(ABC):
57
"""
@@ -9,22 +11,18 @@ class IEmailServiceProvider(ABC):
911

1012
@abstractmethod
1113
def send_email(
12-
self, recipient: str, subject: str, body_html: str, body_text: str
14+
self, templateType: EmailTemplateType, content: EmailContent
1315
) -> dict:
14-
"""
15-
Sends an email using the provider's service.
16+
"""_summary_
17+
18+
Args:
19+
templateType (EmailTemplate): Helps provider determine which
20+
template to use for the given email
21+
content (EmailContent): Contains the recipient and data to be
22+
used in the email
1623
17-
:param recipient: Email address of the recipient
18-
:type recipient: str
19-
:param subject: Subject of the email
20-
:type subject: str
21-
:param body_html: HTML body content of the email
22-
:type body_html: str
23-
:param body_text: Plain text content of the email
24-
:type body_text: str
25-
:return: Provider-specific metadata related to the sent email
26-
(like message ID, status, etc.)
27-
:rtype: dict
28-
:raises Exception: if the email fails to send
24+
Returns:
25+
dict: Provider-specific metadata if any
26+
(like message ID, thread ID, label IDs)
2927
"""
3028
pass

backend/app/routes/send_email.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from fastapi import APIRouter, Depends
44

55
from app.interfaces.email_service import IEmailService
6+
from app.schemas.email_template import EmailContent, EmailTemplateType, MockEmailData
7+
from app.services.email.amazon_ses_provider import (
8+
get_email_service_provider,
9+
)
610
from app.services.email.email_service import EmailService
7-
from app.services.email.email_service_provider import AmazonSESEmailProvider
811

912
router = APIRouter(
1013
prefix="/email",
@@ -13,16 +16,19 @@
1316

1417

1518
def get_email_service() -> IEmailService:
16-
email_provider = AmazonSESEmailProvider(aws_access_key="", aws_secret_key="")
17-
return EmailService(email_provider)
19+
return EmailService(provider=get_email_service_provider())
1820

1921

2022
# TODO (Mayank, Nov 30th) - Remove test emails once email service is fully implemented
21-
@router.post("/send-test-email/")
23+
@router.post("/send-test")
2224
async def send_welcome_email(
2325
recipient: str,
2426
user_name: str,
2527
email_service: Annotated[IEmailService, Depends(get_email_service)],
2628
):
27-
email_service.send_welcome_email(recipient, user_name)
28-
return {"message": f"Welcome email sent to {user_name}!"}
29+
return email_service.send_email(
30+
templateType=EmailTemplateType.TEST,
31+
content=EmailContent[MockEmailData](
32+
recipient=recipient, data=MockEmailData(name=user_name, date="2021-12-01")
33+
),
34+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import json
2+
from abc import ABC
3+
from dataclasses import dataclass
4+
from enum import Enum
5+
from typing import Generic, TypeVar
6+
7+
EmailData = TypeVar("EmailData")
8+
9+
10+
class TemplateData(ABC):
11+
def get_formatted_string(self) -> str:
12+
class_dict = self.__dict__
13+
try:
14+
formatted_string = json.dumps(class_dict) # Try to convert to a JSON string
15+
except (TypeError, ValueError) as e:
16+
raise Exception(f"Error converting class to JSON: {e}")
17+
18+
return formatted_string
19+
20+
21+
@dataclass
22+
class MockEmailData(TemplateData):
23+
name: str
24+
date: str
25+
26+
27+
class EmailTemplateType(Enum):
28+
TEST = "Test"
29+
30+
31+
@dataclass
32+
class EmailContent(Generic[EmailData]):
33+
recipient: str
34+
data: EmailData

backend/app/server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@
99
from .routes import send_email, user
1010
from .utilities.constants import LOGGER_NAME
1111
from .utilities.firebase_init import initialize_firebase
12+
from .utilities.ses.ses_init import ensure_ses_templates
1213

1314
load_dotenv()
1415

15-
1616
log = logging.getLogger(LOGGER_NAME("server"))
1717

1818

1919
@asynccontextmanager
2020
async def lifespan(_: FastAPI):
2121
log.info("Starting up...")
22+
ensure_ses_templates()
2223
models.run_migrations()
2324
initialize_firebase()
2425
yield
@@ -29,12 +30,12 @@ async def lifespan(_: FastAPI):
2930
# running-alembic-migrations-on-fastapi-startup
3031
app = FastAPI(lifespan=lifespan)
3132
app.include_router(user.router)
32-
3333
app.include_router(send_email.router)
3434

3535

3636
@app.get("/")
3737
def read_root():
38+
log.info("Hello World")
3839
return {"Hello": "World"}
3940

4041

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import os
2+
3+
import boto3
4+
from botocore.exceptions import ClientError
5+
6+
from app.interfaces.email_service_provider import IEmailServiceProvider
7+
from app.schemas.email_template import EmailContent, EmailTemplateType
8+
9+
10+
class AmazonSESEmailProvider(IEmailServiceProvider):
11+
"""Amazon SES Email Provider.
12+
13+
This class is responsible for sending emails using Amazon SES.
14+
"""
15+
16+
"""
17+
Args:
18+
aws_access_key (str): AWS Access Key ID
19+
aws_secret_key (str): AWS Secret Access Key
20+
region (str): AWS region where SES is configured
21+
source_email (str): Email address from which the email will be sent
22+
is_sandbox (bool): If True, the amazon provider will only be able to send emails
23+
to previously verified email addresses and domains
24+
"""
25+
26+
def __init__(
27+
self,
28+
aws_access_key: str,
29+
aws_secret_key: str,
30+
region: str,
31+
source_email: str,
32+
is_sandbox: bool = True,
33+
):
34+
self.source_email = source_email
35+
self.is_sandbox = is_sandbox
36+
self.ses_client = boto3.client(
37+
"ses",
38+
region_name=region,
39+
aws_access_key_id=aws_access_key,
40+
aws_secret_access_key=aws_secret_key,
41+
)
42+
43+
self.verified_emails = None
44+
if self.is_sandbox:
45+
response = self.ses_client.list_verified_email_addresses()
46+
self.verified_emails = response.get("VerifiedEmailAddresses", [])
47+
48+
def _verify_email(self, email: str, templateType: EmailTemplateType) -> None:
49+
try:
50+
if self.is_sandbox and email not in self.verified_emails:
51+
self.ses_client.verify_email_identity(EmailAddress=email)
52+
print(f"Verification email sent to {email}.")
53+
if self.ses_client.get_template(TemplateName=templateType.value):
54+
print(f"Template {templateType.value} exists.")
55+
except Exception as e:
56+
print(f"Failed to verify email: {e}")
57+
58+
def send_email(
59+
self, templateType: EmailTemplateType, content: EmailContent
60+
) -> dict:
61+
try:
62+
self._verify_email(content.recipient, templateType)
63+
64+
template_data = content.data.get_formatted_string()
65+
66+
response = self.ses_client.send_templated_email(
67+
Source=self.source_email,
68+
Destination={"ToAddresses": [content.recipient]},
69+
Template=templateType.value,
70+
TemplateData=template_data,
71+
)
72+
73+
return {
74+
"message": "Email sent successfully!",
75+
"message_id": response["MessageId"],
76+
}
77+
except ClientError as e:
78+
return {"error": f"An error occurred: {e.response['Error']['Message']}"}
79+
80+
81+
def get_email_service_provider() -> IEmailServiceProvider:
82+
return AmazonSESEmailProvider(
83+
aws_access_key=os.getenv("AWS_ACCESS_KEY"),
84+
aws_secret_key=os.getenv("AWS_SECRET_KEY"),
85+
region=os.getenv("AWS_REGION"),
86+
source_email=os.getenv("SES_SOURCE_EMAIL"),
87+
)
Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1+
import logging
2+
13
from app.interfaces.email_service import IEmailService
24
from app.interfaces.email_service_provider import IEmailServiceProvider
5+
from app.schemas.email_template import EmailContent, EmailData, EmailTemplateType
36

47

5-
# TODO (Mayank, Nov 30th) - Implement the email service methods and use User object
68
class EmailService(IEmailService):
79
def __init__(self, provider: IEmailServiceProvider):
810
self.provider = provider
11+
self.logger = logging.getLogger(__name__)
912

10-
def send_email(self, to: str, subject: str, body: str) -> dict:
11-
pass
12-
13-
def send_welcome_email(self, recipient: str, user_name: str) -> dict:
14-
pass
15-
16-
def send_password_reset_email(self, recipient: str, reset_link: str) -> dict:
17-
pass
18-
19-
def send_notification_email(self, recipient: str, notification_text: str) -> dict:
20-
pass
13+
def send_email(
14+
self, templateType: EmailTemplateType, content: EmailContent[EmailData]
15+
) -> dict:
16+
self.logger.info(
17+
f"Sending email to {content.recipient} with template {templateType.value}"
18+
)
19+
return self.provider.send_email(templateType, content)

backend/app/services/email/email_service_provider.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

backend/app/services/email/email_templates/test.html

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)