Skip to content

Commit 4c3b3b1

Browse files
Umair/sesemail (#68)
## Notion ticket link <!-- Please replace with your ticket's URL --> [Ticket Name](https://www.notion.so/uwblueprintexecs/Emails-27210f3fb1dc804ea227de3164b36bd3?v=27210f3fb1dc8133a6e1000cf4eabc81&source=copy_link) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * Emails now send through amazon ses not going into spam emails <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. Add email to verifed on amazon ses 2. Checkout verify email and resend buttons for both regular users and admin on english and french 3. Check password reset and resend buttons for regular users and admin <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * Template of the email ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] 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 - [ ] 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 --------- Co-authored-by: YashK2005 <[email protected]>
1 parent e0d2069 commit 4c3b3b1

22 files changed

+1169
-111
lines changed

backend/app/interfaces/auth_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ def reset_password(self, email):
7878
def send_email_verification_link(self, email):
7979
"""
8080
Generates an email verification link for the user with the given email
81-
and sends the reset link to that email address
81+
and sends the verification link to that email address
8282
83-
:param email: email of user requesting password reset
83+
:param email: email of user requesting email verification
8484
:type email: str
8585
:raises Exception: if unable to generate link or send email
8686
"""

backend/app/routes/auth.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from fastapi import APIRouter, Depends, HTTPException, Request, Response
1+
import logging
2+
3+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
24
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
35
from sqlalchemy.orm import Session
46

@@ -116,6 +118,32 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_
116118
return Response(status_code=500)
117119

118120

121+
@router.post("/send-email-verification/{email}")
122+
async def send_email_verification(
123+
email: str,
124+
language: str = Query("en", description="Language code: 'en' for English, 'fr' for French"),
125+
auth_service: AuthService = Depends(get_auth_service),
126+
):
127+
try:
128+
# Normalize and validate language
129+
language = language.lower() if language else "en"
130+
if language not in ["en", "fr"]:
131+
language = "en" # Default to English if invalid
132+
133+
# Log for debugging
134+
logger = logging.getLogger(__name__)
135+
logger.info(f"Sending email verification to {email} with language: {language}")
136+
137+
auth_service.send_email_verification_link(email, language)
138+
return Response(status_code=204)
139+
except Exception as e:
140+
# Log error but don't reveal if email exists or not for security reasons
141+
logger = logging.getLogger(__name__)
142+
logger.error(f"Error sending email verification: {str(e)}")
143+
# Always return success even if email doesn't exist
144+
return Response(status_code=204)
145+
146+
119147
@router.get("/me", response_model=UserCreateResponse)
120148
async def get_current_user(
121149
request: Request,

backend/app/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"/auth/register",
4242
"/auth/resetPassword/{email}",
4343
"/auth/verify/{email}",
44+
"/auth/send-email-verification/{email}",
4445
"/health",
4546
"/test-middleware-public",
4647
"/email/send-test-email",

backend/app/services/implementations/auth_service.py

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import os
33

44
import firebase_admin.auth
5-
import requests
65
from fastapi import HTTPException
76

87
from app.utilities.constants import LOGGER_NAME
8+
from app.utilities.ses_email_service import SESEmailService
99

1010
from ...interfaces.auth_service import IAuthService
1111
from ...schemas.auth import AuthResponse, Token
@@ -17,6 +17,7 @@ def __init__(self, logger, user_service):
1717
self.logger = logging.getLogger(LOGGER_NAME("auth_service"))
1818
self.user_service = user_service
1919
self.firebase_client = FirebaseRestClient(logger)
20+
self.ses_email_service = SESEmailService()
2021

2122
def generate_token(self, email: str, password: str) -> AuthResponse:
2223
try:
@@ -48,36 +49,105 @@ def renew_token(self, refresh_token: str) -> Token:
4849

4950
def reset_password(self, email: str) -> None:
5051
try:
51-
# Use Firebase REST API to send password reset email
52-
url = f"https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key={os.getenv('FIREBASE_WEB_API_KEY')}"
53-
data = {
54-
"requestType": "PASSWORD_RESET",
55-
"email": email,
56-
"continueUrl": "http://localhost:3000/set-new-password", # Custom action URL
57-
}
52+
# Use Firebase Admin SDK to generate password reset link
53+
action_code_settings = firebase_admin.auth.ActionCodeSettings(
54+
url="http://localhost:3000/set-new-password",
55+
handle_code_in_app=True,
56+
)
5857

59-
response = requests.post(url, json=data)
60-
response_json = response.json()
58+
reset_link = firebase_admin.auth.generate_password_reset_link(email, action_code_settings)
6159

62-
if response.status_code != 200:
63-
error_message = response_json.get("error", {}).get("message", "Unknown error")
64-
self.logger.error(f"Failed to send password reset email: {error_message}")
65-
# Don't raise exception for security reasons - don't reveal if email exists
66-
return
60+
# Send via SES
61+
email_sent = self.ses_email_service.send_password_reset_email(email, reset_link)
6762

68-
self.logger.info(f"Password reset email sent successfully to {email}")
63+
if email_sent:
64+
self.logger.info(f"Password reset email sent successfully to {email}")
65+
else:
66+
self.logger.warning(
67+
f"Failed to send password reset email to {email}, but link was generated: {reset_link}"
68+
)
69+
# Do not raise, avoid revealing if email exists
6970

7071
except Exception as e:
7172
self.logger.error(f"Failed to reset password: {str(e)}")
7273
# Don't raise exception for security reasons - don't reveal if email exists
7374
return
7475

75-
def send_email_verification_link(self, email: str) -> None:
76+
def send_email_verification_link(self, email: str, language: str = None) -> None:
7677
try:
77-
firebase_admin.auth.generate_email_verification_link(email)
78+
# Get user's first name if available
79+
# Try Firebase first (for display_name), then fall back to database
80+
first_name = None
81+
try:
82+
# Try to get from Firebase user (display_name)
83+
try:
84+
firebase_user = firebase_admin.auth.get_user_by_email(email)
85+
if firebase_user and firebase_user.display_name:
86+
# Extract first name from display_name (e.g., "John Doe" -> "John")
87+
display_name = firebase_user.display_name.strip()
88+
first_name = display_name.split()[0] if display_name else None
89+
if first_name:
90+
self.logger.info(
91+
f"Found first name '{first_name}' from Firebase display_name for user {email}"
92+
)
93+
else:
94+
self.logger.debug(f"Firebase user exists but display_name is None for {email}")
95+
except Exception as firebase_error:
96+
self.logger.debug(f"Could not get Firebase user for {email}: {str(firebase_error)}")
97+
98+
# Fall back to database if Firebase didn't have display_name
99+
if not first_name:
100+
try:
101+
user = self.user_service.get_user_by_email(email)
102+
if user and user.first_name and user.first_name.strip():
103+
first_name = user.first_name.strip()
104+
self.logger.info(f"Found first name '{first_name}' from database for user {email}")
105+
else:
106+
self.logger.debug(f"No first name found in database for user {email}")
107+
except Exception as db_error:
108+
self.logger.debug(f"Could not get user from database for {email}: {str(db_error)}")
109+
except Exception as e:
110+
# If we can't get the user, continue without first name
111+
self.logger.debug(f"Could not retrieve user for email {email}: {str(e)}")
112+
pass
113+
114+
# Normalize and validate language
115+
# If not provided, check environment variable, otherwise default to English
116+
if not language:
117+
language = os.getenv("EMAIL_LANGUAGE", "en").lower()
118+
else:
119+
language = language.lower()
120+
121+
# Only allow 'en' or 'fr', default to 'en' for anything else
122+
if language not in ["en", "fr"]:
123+
language = "en"
124+
125+
# Use Firebase Admin SDK to generate email verification link
126+
action_code_settings = firebase_admin.auth.ActionCodeSettings(
127+
url="http://localhost:3000/action", # URL to redirect after verification
128+
handle_code_in_app=True,
129+
)
130+
131+
# Generate the verification link
132+
verification_link = firebase_admin.auth.generate_email_verification_link(email, action_code_settings)
133+
134+
# Send the verification email via SES (works with any email address)
135+
email_sent = self.ses_email_service.send_verification_email(email, verification_link, first_name, language)
136+
137+
if email_sent:
138+
self.logger.info(f"Email verification sent successfully to {email}")
139+
else:
140+
# If SES fails, we can still provide the link for manual verification
141+
self.logger.warning(
142+
f"Failed to send verification email to {email}, but link was generated: {verification_link}"
143+
)
144+
# For development/testing, you could log the link or store it temporarily
145+
# In production, you might want to implement a fallback mechanism
146+
78147
except Exception as e:
79148
self.logger.error(f"Failed to send verification email: {str(e)}")
80-
raise
149+
# Don't raise exception for security reasons - don't reveal if email exists
150+
return
81151

82152
def is_authorized_by_role(self, access_token: str, roles: set[str]) -> bool:
83153
try:

backend/app/utilities/ses/ses_init.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ def load_file_content(file_path: str) -> str:
3131
return ""
3232

3333

34-
# Function to create SES template
35-
def create_ses_template(template_metadata, ses_client):
34+
# Function to create or update SES template
35+
def create_or_update_ses_template(template_metadata, ses_client, force_update=False):
3636
name = template_metadata["TemplateName"]
3737
try:
3838
text_part = load_file_content(template_metadata["TextPart"])
@@ -47,17 +47,29 @@ def create_ses_template(template_metadata, ses_client):
4747
"TextPart": text_part,
4848
"HtmlPart": html_part,
4949
}
50-
ses_client.create_template(Template=template)
51-
print(f"SES template '{name}' created successfully!")
50+
51+
# Check if template exists
52+
try:
53+
ses_client.get_template(TemplateName=name)
54+
# Template exists, update it
55+
if force_update:
56+
ses_client.update_template(Template=template)
57+
print(f"SES template '{name}' updated successfully!")
58+
else:
59+
print(f"SES template '{name}' already exists. Use force_update=True to update.")
60+
except ClientError as e:
61+
if e.response["Error"]["Code"] == "TemplateDoesNotExist":
62+
# Template doesn't exist, create it
63+
ses_client.create_template(Template=template)
64+
print(f"SES template '{name}' created successfully!")
65+
else:
66+
raise
5267
except ClientError as e:
53-
if e.response["Error"]["Code"] == "TemplateAlreadyExists":
54-
print(f"SES template '{name}' already exists.")
55-
else:
56-
print(f"An error occurred while creating the SES template: {e}")
68+
print(f"An error occurred while processing SES template '{name}': {e}")
5769

5870

5971
# Ensure SES templates are available at app startup
60-
def ensure_ses_templates():
72+
def ensure_ses_templates(force_update=False):
6173
templates_metadata = load_templates_metadata(TEMPLATES_FILE)
6274
aws_region = os.getenv("AWS_REGION")
6375
aws_access_key = os.getenv("AWS_ACCESS_KEY")
@@ -75,14 +87,4 @@ def ensure_ses_templates():
7587
)
7688

7789
for template_metadata in templates_metadata:
78-
name = template_metadata["TemplateName"]
79-
try:
80-
# Check if the template exists
81-
ses_client.get_template(TemplateName=name)
82-
print(f"SES template '{name}' already exists.")
83-
except ClientError as e:
84-
if e.response["Error"]["Code"] == "TemplateDoesNotExist":
85-
print(f"SES template '{name}' does not exist. Creating template...")
86-
create_ses_template(template_metadata, ses_client)
87-
else:
88-
print(f"An error occurred while checking the SES template: {e}")
90+
create_or_update_ses_template(template_metadata, ses_client, force_update=force_update)

backend/app/utilities/ses/ses_templates.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,23 @@
44
"SubjectPart": "Testing Email SES Template",
55
"TemplateName": "Test",
66
"TextPart": "app/utilities/ses/template_files/test.txt"
7+
},
8+
{
9+
"HtmlPart": "app/utilities/ses/template_files/email_verification_en.html",
10+
"SubjectPart": "Verify Your Email Address",
11+
"TemplateName": "EmailVerificationEn",
12+
"TextPart": "app/utilities/ses/template_files/email_verification_en.txt"
13+
},
14+
{
15+
"HtmlPart": "app/utilities/ses/template_files/email_verification_fr.html",
16+
"SubjectPart": "Vérifiez votre adresse courriel",
17+
"TemplateName": "EmailVerificationFr",
18+
"TextPart": "app/utilities/ses/template_files/email_verification_fr.txt"
19+
},
20+
{
21+
"HtmlPart": "app/utilities/ses/template_files/password_reset.html",
22+
"SubjectPart": "Reset Your Password",
23+
"TemplateName": "PasswordReset",
24+
"TextPart": "app/utilities/ses/template_files/password_reset.txt"
725
}
826
]

0 commit comments

Comments
 (0)