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

Email SeS implementation #19

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
83 changes: 35 additions & 48 deletions backend/app/interfaces/email_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,38 @@
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Generic, TypeVar

T = TypeVar("T")


class TemplateData(ABC):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to move this to a schema file? Given that routes is using this too, maybe we should treat this the same as a schema.

def get_formatted_string(self) -> str:
class_dict = self.__dict__
try:
formatted_string = json.dumps(class_dict) # Try to convert to a JSON string
except (TypeError, ValueError) as e:
# Handle errors and return a message instead
return f"Error in converting data to JSON: {e}"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're planning on using this as the basis of other template data, I think we should propagate the error instead of gracefully failing, especially if this is being sent by email.


return formatted_string


@dataclass
class TestEmailData(TemplateData):
name: str
date: str


class EmailTemplate(Enum):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we name this EmailTemplateType to signal that it's an enum?

TEST = "Test"


@dataclass
class EmailContent(Generic[T]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's call T EmailData or EmailPayload to be more concrete

Cool use of generics in this situation though!

recipient: str
data: T


class IEmailService(ABC):
Expand All @@ -8,7 +42,7 @@ class IEmailService(ABC):
"""

@abstractmethod
def send_email(self, to: str, subject: str, body: str) -> dict:
def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
"""
Sends an email with the given parameters.

Expand All @@ -23,50 +57,3 @@ def send_email(self, to: str, subject: str, body: str) -> dict:
:raises Exception: if email was not sent successfully
"""
pass

@abstractmethod
def send_welcome_email(self, recipient: str, user_name: str) -> dict:
"""
Sends a welcome email to the specified user.

:param recipient: Email address of the user
:type recipient: str
:param user_name: Name of the user
:type user_name: str
:return: Provider-specific metadata for the sent email
:rtype: dict
:raises Exception: if email was not sent successfully
"""
pass

@abstractmethod
def send_password_reset_email(self, recipient: str, reset_link: str) -> dict:
"""
Sends a password reset email with the provided reset link.

:param recipient: Email address of the user requesting the reset
:type recipient: str
:param reset_link: Password reset link
:type reset_link: str
:return: Provider-specific metadata for the sent email
:rtype: dict
:raises Exception: if email was not sent successfully
"""
pass

@abstractmethod
def send_notification_email(self, recipient: str, notification_text: str) -> dict:
"""
Sends a notification email to the user with the provided notification text.
Examples of use case include matches completed and ready to view, new messages,
meeting time scheduled, etc.

:param recipient: Email address of the user
:type recipient: str
:param notification_text: The notification content
:type notification_text: str
:return: Provider-specific metadata for the sent email
:rtype: dict
:raises Exception: if email was not sent successfully
"""
pass
6 changes: 3 additions & 3 deletions backend/app/interfaces/email_service_provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod

from app.interfaces.email_service import EmailContent, EmailTemplate


class IEmailServiceProvider(ABC):
"""
Expand All @@ -8,9 +10,7 @@ class IEmailServiceProvider(ABC):
"""

@abstractmethod
def send_email(
self, recipient: str, subject: str, body_html: str, body_text: str
) -> dict:
def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto template vs templateType, as template could be a full formed template class.

"""
Sends an email using the provider's service.

Expand Down
24 changes: 17 additions & 7 deletions backend/app/routes/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

from fastapi import APIRouter, Depends

from app.interfaces.email_service import IEmailService
from app.interfaces.email_service import (
EmailContent,
EmailTemplate,
IEmailService,
TestEmailData,
)
from app.services.email.email_service import EmailService
from app.services.email.email_service_provider import AmazonSESEmailProvider
from app.services.email.email_service_provider import (
get_email_service_provider,
)

router = APIRouter(
prefix="/email",
Expand All @@ -13,16 +20,19 @@


def get_email_service() -> IEmailService:
email_provider = AmazonSESEmailProvider(aws_access_key="", aws_secret_key="")
return EmailService(email_provider)
return EmailService(provider=get_email_service_provider())


# TODO (Mayank, Nov 30th) - Remove test emails once email service is fully implemented
@router.post("/send-test-email/")
@router.post("/send-test")
async def send_welcome_email(
recipient: str,
user_name: str,
email_service: Annotated[IEmailService, Depends(get_email_service)],
):
email_service.send_welcome_email(recipient, user_name)
return {"message": f"Welcome email sent to {user_name}!"}
return email_service.send_email(
template=EmailTemplate.TEST,
content=EmailContent[TestEmailData](
recipient=recipient, data=TestEmailData(name=user_name, date="2021-12-01")
),
)
10 changes: 6 additions & 4 deletions backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@
load_dotenv()

# we need to load env variables before initialization code runs
from . import models # noqa: E402
# from . import models # noqa: E402
from .routes import user # noqa: E402
from .utilities.firebase_init import initialize_firebase # noqa: E402
from .utilities.ses.ses_init import ensure_ses_templates # noqa: E402

log = logging.getLogger("uvicorn")


@asynccontextmanager
async def lifespan(_: FastAPI):
log.info("Starting up...")
models.run_migrations()
initialize_firebase()
ensure_ses_templates()
# models.run_migrations()
# initialize_firebase()
yield
log.info("Shutting down...")

Expand All @@ -36,6 +37,7 @@ async def lifespan(_: FastAPI):

@app.get("/")
def read_root():
log.info("Hello World")
return {"Hello": "World"}


Expand Down
32 changes: 23 additions & 9 deletions backend/app/services/email/email_service.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
from app.interfaces.email_service import IEmailService
import logging

from app.interfaces.email_service import EmailContent, EmailTemplate, IEmailService, T
from app.interfaces.email_service_provider import IEmailServiceProvider


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

# def render_templates(self, template: EmailTemplate) -> tuple[str, str]:
# html_path = self.template_dir / f"{template.value}.html"
# text_path = self.template_dir / f"{template.value}.txt"

def send_email(self, to: str, subject: str, body: str) -> dict:
pass
# # Check if both files exist
# if not html_path.exists():
# raise FileNotFoundError(f"HTML template not found: {html_path}")
# if not text_path.exists():
# raise FileNotFoundError(f"Text template not found: {text_path}")

def send_welcome_email(self, recipient: str, user_name: str) -> dict:
pass
# # Read the templates
# html_template = html_path.read_text(encoding="utf-8")
# text_template = text_path.read_text(encoding="utf-8")

def send_password_reset_email(self, recipient: str, reset_link: str) -> dict:
pass
# return html_template, text_template

def send_notification_email(self, recipient: str, notification_text: str) -> dict:
pass
def send_email(self, template: EmailTemplate, content: EmailContent[T]) -> dict:
self.logger.info(
f"Sending email to {content.recipient} with template {template.value}"
)
# html_template, text_template = self.render_templates(template)
return self.provider.send_email(template, content)
76 changes: 68 additions & 8 deletions backend/app/services/email/email_service_provider.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,72 @@
import os

import boto3
from botocore.exceptions import ClientError

from app.interfaces.email_service import EmailContent, EmailTemplate
from app.interfaces.email_service_provider import IEmailServiceProvider


class AmazonSESEmailProvider(IEmailServiceProvider):
def __init__(self, aws_access_key: str, aws_secret_key: str):
pass

# TODO (Mayank, Nov 30th) - Create an email object to pass into this method
def send_email(
self, recipient: str, subject: str, body_html: str, body_text: str
) -> dict:
pass
def __init__(
self,
aws_access_key: str,
aws_secret_key: str,
region: str,
source_email: str,
is_sandbox: bool = True,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does is_sandbox do? For quick glancers, would you be able to leave a comment describing this option.

):
self.source_email = source_email
self.is_sandbox = is_sandbox
self.ses_client = boto3.client(
"ses",
region_name=region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
)

self.verified_emails = None
if self.is_sandbox:
response = self.ses_client.list_verified_email_addresses()
self.verified_emails = response.get("VerifiedEmailAddresses", [])

def _verify_email(self, email: str):
if not self.is_sandbox:
return
try:
if email not in self.verified_emails:
self.ses_client.verify_email_identity(EmailAddress=email)
print(f"Verification email sent to {email}.")
except Exception as e:
print(f"Failed to verify email: {e}")

def send_email(self, template: EmailTemplate, content: EmailContent) -> dict:
try:
self._verify_email(content.recipient)

self.ses_client.get_template(TemplateName=template.value)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this line do right now? Should it be getting a response value?


template_data = content.data.get_formatted_string()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Imo you should group this with self._verify_email(content.recipient), I got confused for a second reading the code given that get_template doesn't return anything right now.


response = self.ses_client.send_templated_email(
Source=self.source_email,
Destination={"ToAddresses": [content.recipient]},
Template=template.value,
TemplateData=template_data,
)

return {
"message": "Email sent successfully!",
"message_id": response["MessageId"],
}
except ClientError as e:
return {"error": f"An error occurred: {e.response['Error']['Message']}"}


def get_email_service_provider() -> IEmailServiceProvider:
return AmazonSESEmailProvider(
aws_access_key=os.getenv("AWS_ACCESS_KEY"),
aws_secret_key=os.getenv("AWS_SECRET_KEY"),
region=os.getenv("AWS_REGION"),
source_email=os.getenv("SES_SOURCE_EMAIL"),
)
6 changes: 0 additions & 6 deletions backend/app/services/email/email_templates/test.html

This file was deleted.

Loading
Loading