Skip to content

Commit b2b2ed8

Browse files
committed
added template email sending and template init on ses
1 parent c2a6548 commit b2b2ed8

File tree

11 files changed

+202
-40
lines changed

11 files changed

+202
-40
lines changed

backend/app/interfaces/email_service.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,38 @@
1+
import json
12
from abc import ABC, abstractmethod
3+
from dataclasses import dataclass
4+
from enum import Enum
5+
from typing import Generic, TypeVar
6+
7+
T = TypeVar("T")
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+
# Handle errors and return a message instead
17+
return f"Error in converting data to JSON: {e}"
18+
19+
return formatted_string
20+
21+
22+
@dataclass
23+
class TestEmailData(TemplateData):
24+
name: str
25+
date: str
26+
27+
28+
class EmailTemplate(Enum):
29+
TEST = "Test"
30+
31+
32+
@dataclass
33+
class EmailContent(Generic[T]):
34+
recipient: str
35+
data: T
236

337

438
class IEmailService(ABC):
@@ -8,7 +42,7 @@ class IEmailService(ABC):
842
"""
943

1044
@abstractmethod
11-
def send_email(self, subject: str, recipient: str, body_html: str) -> None:
45+
def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
1246
"""
1347
Sends an email with the given parameters.
1448

backend/app/interfaces/email_service_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from abc import ABC, abstractmethod
22

3+
from app.interfaces.email_service import EmailContent, EmailTemplate
4+
35

46
class IEmailServiceProvider(ABC):
57
"""
@@ -8,7 +10,7 @@ class IEmailServiceProvider(ABC):
810
"""
911

1012
@abstractmethod
11-
def send_email(self, recipient: str, subject: str) -> None:
13+
def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
1214
"""
1315
Sends an email using the provider's service.
1416

backend/app/routes/email.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import logging
21
from typing import Annotated
32

43
from fastapi import APIRouter, Depends
54

6-
from app.interfaces.email_service import IEmailService
5+
from app.interfaces.email_service import (
6+
EmailContent,
7+
EmailTemplate,
8+
IEmailService,
9+
TestEmailData,
10+
)
711
from app.services.email.email_service import EmailService
812
from app.services.email.email_service_provider import (
913
get_email_service_provider,
@@ -14,11 +18,9 @@
1418
tags=["email"],
1519
)
1620

17-
log = logging.getLogger("uvicorn")
18-
1921

2022
def get_email_service() -> IEmailService:
21-
return EmailService(get_email_service_provider())
23+
return EmailService(provider=get_email_service_provider())
2224

2325

2426
# TODO (Mayank, Nov 30th) - Remove test emails once email service is fully implemented
@@ -28,9 +30,9 @@ async def send_welcome_email(
2830
user_name: str,
2931
email_service: Annotated[IEmailService, Depends(get_email_service)],
3032
):
31-
log.info(f"Main Sending welcome email to {user_name} at {recipient}")
32-
email_service.send_email(
33-
subject="Welcome to the app!",
34-
recipient=recipient,
33+
return email_service.send_email(
34+
template=EmailTemplate.TEST,
35+
content=EmailContent[TestEmailData](
36+
recipient=recipient, data=TestEmailData(name=user_name, date="2021-12-01")
37+
),
3538
)
36-
return {"message": f"Welcome email sent to {user_name}!"}

backend/app/server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010
load_dotenv()
1111

1212
# we need to load env variables before initialization code runs
13+
# from . import models # noqa: E402
1314
from .routes import user # noqa: E402
15+
from .utilities.ses.ses_init import ensure_ses_templates # noqa: E402
1416

1517
log = logging.getLogger("uvicorn")
1618

1719

1820
@asynccontextmanager
1921
async def lifespan(_: FastAPI):
2022
log.info("Starting up...")
23+
ensure_ses_templates()
2124
# models.run_migrations()
2225
# initialize_firebase()
2326
yield
Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from app.interfaces.email_service import IEmailService
3+
from app.interfaces.email_service import EmailContent, EmailTemplate, IEmailService, T
44
from app.interfaces.email_service_provider import IEmailServiceProvider
55

66

@@ -10,6 +10,25 @@ def __init__(self, provider: IEmailServiceProvider):
1010
self.provider = provider
1111
self.logger = logging.getLogger(__name__)
1212

13-
def send_email(self, subject: str, recipient: str, body_html: str = "") -> None:
14-
self.logger.info(f"Sending email to {recipient} with subject: {subject}")
15-
self.provider.send_email(subject, recipient)
13+
# def render_templates(self, template: EmailTemplate) -> tuple[str, str]:
14+
# html_path = self.template_dir / f"{template.value}.html"
15+
# text_path = self.template_dir / f"{template.value}.txt"
16+
17+
# # Check if both files exist
18+
# if not html_path.exists():
19+
# raise FileNotFoundError(f"HTML template not found: {html_path}")
20+
# if not text_path.exists():
21+
# raise FileNotFoundError(f"Text template not found: {text_path}")
22+
23+
# # Read the templates
24+
# html_template = html_path.read_text(encoding="utf-8")
25+
# text_template = text_path.read_text(encoding="utf-8")
26+
27+
# return html_template, text_template
28+
29+
def send_email(self, template: EmailTemplate, content: EmailContent[T]) -> dict:
30+
self.logger.info(
31+
f"Sending email to {content.recipient} with template {template.value}"
32+
)
33+
# html_template, text_template = self.render_templates(template)
34+
return self.provider.send_email(template, content)

backend/app/services/email/email_service_provider.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import os
22

33
import boto3
4-
from botocore.exceptions import BotoCoreError, ClientError
5-
from fastapi import HTTPException
4+
from botocore.exceptions import ClientError
65

6+
from app.interfaces.email_service import EmailContent, EmailTemplate
77
from app.interfaces.email_service_provider import IEmailServiceProvider
88

99

@@ -25,33 +25,42 @@ def __init__(
2525
aws_secret_access_key=aws_secret_key,
2626
)
2727

28+
self.verified_emails = None
29+
if self.is_sandbox:
30+
response = self.ses_client.list_verified_email_addresses()
31+
self.verified_emails = response.get("VerifiedEmailAddresses", [])
32+
2833
def _verify_email(self, email: str):
2934
if not self.is_sandbox:
3035
return
3136
try:
32-
self.client.verify_email_identity(EmailAddress=email)
33-
print(f"Verification email sent to {email}.")
37+
if email not in self.verified_emails:
38+
self.ses_client.verify_email_identity(EmailAddress=email)
39+
print(f"Verification email sent to {email}.")
3440
except Exception as e:
3541
print(f"Failed to verify email: {e}")
3642

37-
def send_email(self, subject: str, recipient: str) -> None:
43+
def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
3844
try:
39-
self._verify_email(recipient)
40-
self.ses_client.send_email(
45+
self._verify_email(content.recipient)
46+
47+
self.ses_client.get_template(TemplateName=template.value)
48+
49+
template_data = content.data.get_formatted_string()
50+
51+
response = self.ses_client.send_templated_email(
4152
Source=self.source_email,
42-
Destination={"ToAddresses": [recipient]},
43-
Message={
44-
"Subject": {"Data": subject},
45-
"Body": {"Text": {"Data": "Hello, this is a test email!"}},
46-
},
53+
Destination={"ToAddresses": [content.recipient]},
54+
Template=template.value,
55+
TemplateData=template_data,
4756
)
48-
except BotoCoreError as e:
49-
raise HTTPException(status_code=500, detail=f"SES BotoCoreError: {e}")
57+
58+
return {
59+
"message": "Email sent successfully!",
60+
"message_id": response["MessageId"],
61+
}
5062
except ClientError as e:
51-
raise HTTPException(
52-
status_code=500,
53-
detail=f"SES ClientError: {e.response['Error']['Message']}",
54-
)
63+
return {"error": f"An error occurred: {e.response['Error']['Message']}"}
5564

5665

5766
def get_email_service_provider() -> IEmailServiceProvider:

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

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import json
2+
import os
3+
from typing import Dict
4+
5+
import boto3
6+
from botocore.exceptions import ClientError
7+
8+
TEMPLATES_FILE = "app/utilities/ses/ses_templates.json"
9+
TEMPLATES_DIR = "app/utilities/ses/template_files"
10+
11+
ses_client = boto3.client(
12+
"ses",
13+
region_name=os.getenv("AWS_REGION"),
14+
aws_access_key_id=os.getenv("AWS_ACCESS_KEY"),
15+
aws_secret_access_key=os.getenv("AWS_SECRET_KEY"),
16+
)
17+
18+
19+
def load_templates_metadata(file_path: str) -> Dict:
20+
try:
21+
with open(file_path, "r") as file:
22+
return json.load(file)
23+
except FileNotFoundError:
24+
print(f"Error: {file_path} not found.")
25+
return []
26+
except json.JSONDecodeError as e:
27+
print(f"Error parsing {file_path}: {e}")
28+
return []
29+
30+
31+
def load_file_content(file_path: str) -> str:
32+
"""Reads the content of a file."""
33+
try:
34+
with open(file_path, "r") as file:
35+
return file.read()
36+
except FileNotFoundError:
37+
print(f"Error: File '{file_path}' not found.")
38+
return ""
39+
40+
41+
templates_metadata = load_templates_metadata(TEMPLATES_FILE)
42+
43+
44+
# Function to create SES template
45+
def create_ses_template(template_metadata):
46+
name = template_metadata["TemplateName"]
47+
try:
48+
text_part = load_file_content(template_metadata["TextPart"])
49+
html_part = load_file_content(template_metadata["HtmlPart"])
50+
if not text_part or not html_part:
51+
print(f"Skipping template '{name}' missing content.")
52+
return
53+
54+
template = {
55+
"TemplateName": template_metadata["TemplateName"],
56+
"SubjectPart": template_metadata["SubjectPart"],
57+
"TextPart": text_part,
58+
"HtmlPart": html_part,
59+
}
60+
ses_client.create_template(Template=template)
61+
print(f"SES template '{name}' created successfully!")
62+
except ClientError as e:
63+
if e.response["Error"]["Code"] == "TemplateAlreadyExists":
64+
print(f"SES template '{name}' already exists.")
65+
else:
66+
print(f"An error occurred while creating the SES template: {e}")
67+
68+
69+
# Ensure SES templates are available at app startup
70+
def ensure_ses_templates():
71+
for template_metadata in templates_metadata:
72+
name = template_metadata["TemplateName"]
73+
try:
74+
# Check if the template exists
75+
ses_client.get_template(TemplateName=name)
76+
print(f"SES template '{name}' already exists.")
77+
except ClientError as e:
78+
if e.response["Error"]["Code"] == "TemplateDoesNotExist":
79+
print(f"SES template '{name}' does not exist. Creating template...")
80+
create_ses_template(template_metadata)
81+
else:
82+
print(f"An error occurred while checking the SES template: {e}")
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
{
3+
"HtmlPart": "app/utilities/ses/template_files/test.html",
4+
"SubjectPart": "Testing Email SES Template",
5+
"TemplateName": "Test",
6+
"TextPart": "app/utilities/ses/template_files/test.txt"
7+
}
8+
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<html>
2+
<body>
3+
<h1>Welcome, {{name}}!</h1>
4+
<p>We are glad to have you with us. Thank you for joining us on {{date}}.</p>
5+
</body>
6+
</html>

0 commit comments

Comments
 (0)