Skip to content

Commit d80d9a1

Browse files
YashK2005claude
andauthored
Yash/email templates bilingual (#88)
TLDR: Added the remaining email templates + the translations for the french versions. All of the emails are now sent at the right places EXCEPT we need to finish the flow for when a volunteer accepts a time proposed by the participant and send an email to the volunteer and to the participant here. Each email template is 200+ lines of html which is why its so long :( <img width="1101" height="706" alt="Screenshot 2025-12-19 at 4 44 06 PM" src="https://github.com/user-attachments/assets/9a8b7f44-792c-4d8d-b219-f6d723eb01c3" /> ## Summary This PR implements a complete bilingual email system with automated sending at key points in the user workflow. The system supports both English and French, with automatic language detection from user preferences and timezone-aware scheduling notifications. ## Changes ### 1. Bilingual Email Template System (Commit 1) **Template Infrastructure:** - Reorganized email templates into structured folders: `base/`, `source/`, `compiled/`, `text/` - Created Jinja2-based template system with base templates for EN and FR - Added custom `PreservingUndefined` class to preserve `{{}}` syntax for AWS SES variable replacement - Updated `update_ses_templates.py` to compile `.j2` source files to HTML **Email Templates (7 types × 2 languages = 14 templates):** - Email verification (EN/FR) - Password reset (EN/FR) - Intake form confirmation (EN/FR) - Matches available (EN/FR) - Call scheduled (EN/FR) - Participant requested new times (EN/FR) - Volunteer accepted new times (EN/FR) **Email Service Updates:** - Updated `SESEmailService` to support language parameter for all email methods - Added separate source email addresses for EN (`[email protected]`) and FR (`[email protected]`) - Implemented dynamic subject lines and personalized greetings ### 2. Automated Email Sending (Commit 2) **Email Triggers Implemented:** 1. **Intake Form Confirmation** (`intake.py:273-286`) - Sent immediately after intake form submission 2. **Password Reset** (`auth_service.py:50-102`) - Enhanced with language detection from user database - Retrieves user's first name from DB with Firebase fallback 3. **Matches Available - Volunteer** (`match_service.py:99-121`) - Sent when admin creates new matches 4. **Matches Available - Participant** (`match_service.py:556-575`) - Sent when volunteer accepts match and sends availability 5. **Call Scheduled - Both Users** (`match_service.py:284-356`) - Sent to both participant and volunteer after call is scheduled - **Timezone conversion**: Each user receives time in their own timezone (EST, PST, etc.) - Uses `get_timezone_from_abbreviation()` utility to convert Canadian timezone abbreviations 6. **Participant Requested New Times** (`match_service.py:417-444`) - Sent to volunteer when participant requests alternative meeting times **Technical Implementation:** - Language detection: Retrieves user's language from database (`Language` enum: "en" or "fr"), defaults to English - Timezone handling: Converts UTC times to user's local timezone using stored timezone abbreviations (NST, AST, EST, CST, MST, PST) - Error handling: All email sends wrapped in try/except to log errors without failing main operations - Package structure: Added missing `__init__.py` files to `app/utilities/` and `app/utilities/ses/` for proper IDE import resolution ## Testing Considerations - All emails default to English if user language is not set - Timezone defaults to EST (America/Toronto) if user timezone is not set - Email send failures are logged but don't interrupt workflow (form submissions, match creation, scheduling) - URLs currently hardcoded to `http://localhost:3000` (production URLs to be configured via environment variables later) ## Future Work - Implement "Volunteer accepted new times" email (endpoint doesn't exist yet) - Replace hardcoded URLs with `FRONTEND_URL` environment variable for production deployment - Consider retry mechanism for failed email sends ## Notion ticket link <!-- Please replace with your ticket's URL --> [Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * ## 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: Claude Sonnet 4.5 <[email protected]>
1 parent 0e2d621 commit d80d9a1

File tree

62 files changed

+4282
-164
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+4282
-164
lines changed

backend/app/routes/intake.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.schemas.user import UserRole
1414
from app.services.implementations.form_processor import FormProcessor
1515
from app.utilities.db_utils import get_db
16+
from app.utilities.ses_email_service import SESEmailService
1617

1718
# ===== Schemas =====
1819

@@ -269,6 +270,21 @@ async def create_form_submission(
269270
db.commit()
270271
db.refresh(db_submission)
271272

273+
# Send intake form confirmation email for intake forms
274+
if form and form.type == "intake":
275+
try:
276+
ses_service = SESEmailService()
277+
# Get language (enum values are already "en" or "fr")
278+
language = target_user.language.value if target_user.language else "en"
279+
280+
first_name = target_user.first_name if target_user.first_name else None
281+
ses_service.send_intake_form_confirmation_email(
282+
to_email=target_user.email, first_name=first_name, language=language
283+
)
284+
except Exception as e:
285+
# Log error but don't fail the request
286+
print(f"Failed to send intake form confirmation email: {str(e)}")
287+
272288
# Build response dict
273289
response_dict = {
274290
"id": db_submission.id,

backend/app/services/implementations/auth_service.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ def renew_token(self, refresh_token: str) -> Token:
4949

5050
def reset_password(self, email: str) -> None:
5151
try:
52+
# Get user's first name and language if available
53+
first_name = None
54+
language = "en" # Default to English
55+
try:
56+
# Try database first, then fall back to Firebase
57+
try:
58+
user = self.user_service.get_user_by_email(email)
59+
if user:
60+
if user.first_name and user.first_name.strip():
61+
first_name = user.first_name.strip()
62+
# Get language from user (enum values are already "en" or "fr")
63+
if user.language:
64+
language = user.language.value
65+
except Exception:
66+
pass
67+
68+
# Fall back to Firebase if database didn't have first_name
69+
if not first_name:
70+
try:
71+
firebase_user = firebase_admin.auth.get_user_by_email(email)
72+
if firebase_user and firebase_user.display_name:
73+
display_name = firebase_user.display_name.strip()
74+
first_name = display_name.split()[0] if display_name else None
75+
except Exception:
76+
pass
77+
except Exception:
78+
pass
79+
5280
# Use Firebase Admin SDK to generate password reset link
5381
action_code_settings = firebase_admin.auth.ActionCodeSettings(
5482
url="http://localhost:3000/set-new-password",
@@ -57,8 +85,8 @@ def reset_password(self, email: str) -> None:
5785

5886
reset_link = firebase_admin.auth.generate_password_reset_link(email, action_code_settings)
5987

60-
# Send via SES
61-
email_sent = self.ses_email_service.send_password_reset_email(email, reset_link)
88+
# Send via SES with language
89+
email_sent = self.ses_email_service.send_password_reset_email(email, reset_link, first_name, language)
6290

6391
if email_sent:
6492
self.logger.info(f"Password reset email sent successfully to {email}")

backend/app/services/implementations/match_service.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from app.schemas.time_block import TimeBlockEntity, TimeRange
2626
from app.schemas.user import UserRole
27+
from app.utilities.ses_email_service import SESEmailService
2728
from app.utilities.timezone_utils import get_timezone_from_abbreviation
2829

2930
SCHEDULE_CLEANUP_STATUSES = {
@@ -95,6 +96,28 @@ async def create_matches(self, req: MatchCreateRequest) -> MatchCreateResponse:
9596
for match in created_matches:
9697
self.db.refresh(match)
9798

99+
# Send "matches available" email to each volunteer
100+
ses_service = SESEmailService()
101+
for match in created_matches:
102+
try:
103+
volunteer = self.db.get(User, match.volunteer_id)
104+
if volunteer and volunteer.email:
105+
# Get volunteer's language (enum values are already "en" or "fr")
106+
language = volunteer.language.value if volunteer.language else "en"
107+
108+
first_name = volunteer.first_name if volunteer.first_name else None
109+
matches_url = "http://localhost:3000/volunteer/dashboard"
110+
111+
ses_service.send_matches_available_email(
112+
to_email=volunteer.email,
113+
first_name=first_name,
114+
matches_url=matches_url,
115+
language=language,
116+
)
117+
except Exception as e:
118+
# Log error but don't fail the match creation
119+
self.logger.error(f"Failed to send matches available email to volunteer {match.volunteer_id}: {e}")
120+
98121
responses = [self._build_match_response(match) for match in created_matches]
99122
return MatchCreateResponse(matches=responses)
100123

@@ -258,6 +281,90 @@ async def schedule_match(
258281
self.db.commit()
259282
self.db.refresh(match)
260283

284+
# Send "call scheduled" email to both participant and volunteer
285+
try:
286+
# Load participant and volunteer with their data
287+
participant = (
288+
self.db.query(User)
289+
.options(joinedload(User.user_data))
290+
.filter(User.id == match.participant_id)
291+
.first()
292+
)
293+
volunteer = (
294+
self.db.query(User)
295+
.options(joinedload(User.user_data))
296+
.filter(User.id == match.volunteer_id)
297+
.first()
298+
)
299+
300+
if participant and volunteer and match.confirmed_time:
301+
ses_service = SESEmailService()
302+
confirmed_time_utc = match.confirmed_time.start_time
303+
304+
# Get participant's timezone and language
305+
participant_tz = ZoneInfo("America/Toronto") # Default to EST
306+
if participant.user_data and participant.user_data.timezone:
307+
tz_result = get_timezone_from_abbreviation(participant.user_data.timezone)
308+
if tz_result:
309+
participant_tz = tz_result
310+
311+
participant_language = participant.language.value if participant.language else "en"
312+
313+
# Get volunteer's timezone and language
314+
volunteer_tz = ZoneInfo("America/Toronto") # Default to EST
315+
if volunteer.user_data and volunteer.user_data.timezone:
316+
tz_result = get_timezone_from_abbreviation(volunteer.user_data.timezone)
317+
if tz_result:
318+
volunteer_tz = tz_result
319+
320+
volunteer_language = volunteer.language.value if volunteer.language else "en"
321+
322+
# Convert time to participant's timezone
323+
participant_time = confirmed_time_utc.astimezone(participant_tz)
324+
participant_date = participant_time.strftime("%B %d, %Y")
325+
participant_time_str = participant_time.strftime("%I:%M %p")
326+
participant_tz_abbr = participant_time.strftime("%Z")
327+
328+
# Convert time to volunteer's timezone
329+
volunteer_time = confirmed_time_utc.astimezone(volunteer_tz)
330+
volunteer_date = volunteer_time.strftime("%B %d, %Y")
331+
volunteer_time_str = volunteer_time.strftime("%I:%M %p")
332+
volunteer_tz_abbr = volunteer_time.strftime("%Z")
333+
334+
# Send to participant
335+
if participant.email:
336+
ses_service.send_call_scheduled_email(
337+
to_email=participant.email,
338+
match_name=f"{volunteer.first_name} {volunteer.last_name}"
339+
if volunteer.first_name and volunteer.last_name
340+
else "Your volunteer",
341+
date=participant_date,
342+
time=participant_time_str,
343+
timezone=participant_tz_abbr,
344+
first_name=participant.first_name,
345+
scheduled_calls_url="http://localhost:3000/participant/dashboard",
346+
language=participant_language,
347+
)
348+
349+
# Send to volunteer
350+
if volunteer.email:
351+
ses_service.send_call_scheduled_email(
352+
to_email=volunteer.email,
353+
match_name=f"{participant.first_name} {participant.last_name}"
354+
if participant.first_name and participant.last_name
355+
else "Your participant",
356+
date=volunteer_date,
357+
time=volunteer_time_str,
358+
timezone=volunteer_tz_abbr,
359+
first_name=volunteer.first_name,
360+
scheduled_calls_url="http://localhost:3000/volunteer/dashboard",
361+
language=volunteer_language,
362+
)
363+
364+
except Exception as e:
365+
# Log error but don't fail the scheduling
366+
self.logger.error(f"Failed to send call scheduled emails for match {match_id}: {e}")
367+
261368
return self._build_match_detail(match)
262369

263370
except HTTPException:
@@ -317,6 +424,35 @@ async def request_new_times(
317424
self.db.commit()
318425
self.db.refresh(match)
319426

427+
# Send "participant requested new times" email to volunteer
428+
try:
429+
# Load participant and volunteer
430+
participant = self.db.get(User, match.participant_id)
431+
volunteer = self.db.get(User, match.volunteer_id)
432+
433+
if participant and volunteer and volunteer.email:
434+
# Get volunteer's language
435+
volunteer_language = volunteer.language.value if volunteer.language else "en"
436+
437+
# Get participant's name for email
438+
participant_name = (
439+
f"{participant.first_name} {participant.last_name}"
440+
if participant.first_name and participant.last_name
441+
else "A participant"
442+
)
443+
444+
ses_service = SESEmailService()
445+
ses_service.send_participant_requested_new_times_email(
446+
to_email=volunteer.email,
447+
participant_name=participant_name,
448+
first_name=volunteer.first_name,
449+
matches_url="http://localhost:3000/volunteer/dashboard",
450+
language=volunteer_language,
451+
)
452+
except Exception as e:
453+
# Log error but don't fail the request
454+
self.logger.error(f"Failed to send participant requested new times email for match {match_id}: {e}")
455+
320456
return self._build_match_detail(match)
321457

322458
except HTTPException:
@@ -530,6 +666,27 @@ async def volunteer_accept_match(
530666
self.db.commit()
531667
self.db.refresh(match)
532668

669+
# Send "matches available" email to participant
670+
try:
671+
participant = match.participant
672+
if participant and participant.email:
673+
# Get participant's language (enum values are already "en" or "fr")
674+
language = participant.language.value if participant.language else "en"
675+
676+
first_name = participant.first_name if participant.first_name else None
677+
matches_url = "http://localhost:3000/participant/dashboard"
678+
679+
ses_service = SESEmailService()
680+
ses_service.send_matches_available_email(
681+
to_email=participant.email,
682+
first_name=first_name,
683+
matches_url=matches_url,
684+
language=language,
685+
)
686+
except Exception as e:
687+
# Log error but don't fail the match acceptance
688+
self.logger.error(f"Failed to send matches available email to participant {match.participant_id}: {e}")
689+
533690
# Return match detail for participant view (includes suggested times)
534691
return self._build_match_detail(match)
535692
except HTTPException:

backend/app/utilities/__init__.py

Whitespace-only changes.

backend/app/utilities/ses/__init__.py

Whitespace-only changes.
Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,92 @@
11
[
22
{
3-
"HtmlPart": "app/utilities/ses/template_files/test.html",
3+
"HtmlPart": "app/utilities/ses/template_files/compiled/test.html",
44
"SubjectPart": "Testing Email SES Template",
55
"TemplateName": "Test",
6-
"TextPart": "app/utilities/ses/template_files/test.txt"
6+
"TextPart": "app/utilities/ses/template_files/text/test.txt"
77
},
88
{
9-
"HtmlPart": "app/utilities/ses/template_files/email_verification_en.html",
10-
"SubjectPart": "Verify Your Email Address",
9+
"HtmlPart": "app/utilities/ses/template_files/compiled/email_verification_en.html",
10+
"SubjectPart": "{{first_name}}, confirm your email - First Connection Peer Support Program",
1111
"TemplateName": "EmailVerificationEn",
12-
"TextPart": "app/utilities/ses/template_files/email_verification_en.txt"
12+
"TextPart": "app/utilities/ses/template_files/text/email_verification_en.txt"
1313
},
1414
{
15-
"HtmlPart": "app/utilities/ses/template_files/email_verification_fr.html",
16-
"SubjectPart": "Vérifiez votre adresse courriel",
15+
"HtmlPart": "app/utilities/ses/template_files/compiled/email_verification_fr.html",
16+
"SubjectPart": "{{first_name}}, confirmation de l'adresse courriel – Programme de soutien par les pairs Premier contact",
1717
"TemplateName": "EmailVerificationFr",
18-
"TextPart": "app/utilities/ses/template_files/email_verification_fr.txt"
18+
"TextPart": "app/utilities/ses/template_files/text/email_verification_fr.txt"
1919
},
2020
{
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"
21+
"HtmlPart": "app/utilities/ses/template_files/compiled/password_reset_en.html",
22+
"SubjectPart": "Reset Your Password - First Connection Peer Support Program",
23+
"TemplateName": "PasswordResetEn",
24+
"TextPart": "app/utilities/ses/template_files/text/password_reset_en.txt"
25+
},
26+
{
27+
"HtmlPart": "app/utilities/ses/template_files/compiled/password_reset_fr.html",
28+
"SubjectPart": "Réinitialisation du mot de passe – Programme de soutien par les pairs Premier contact",
29+
"TemplateName": "PasswordResetFr",
30+
"TextPart": "app/utilities/ses/template_files/text/password_reset_fr.txt"
31+
},
32+
{
33+
"HtmlPart": "app/utilities/ses/template_files/compiled/intake_form_confirmation_en.html",
34+
"SubjectPart": "We received your intake form - First Connection Peer Support Program",
35+
"TemplateName": "IntakeFormConfirmationEn",
36+
"TextPart": "app/utilities/ses/template_files/text/intake_form_confirmation_en.txt"
37+
},
38+
{
39+
"HtmlPart": "app/utilities/ses/template_files/compiled/intake_form_confirmation_fr.html",
40+
"SubjectPart": "Réception de votre formulaire de demande – Programme de soutien par les pairs Premier contact",
41+
"TemplateName": "IntakeFormConfirmationFr",
42+
"TextPart": "app/utilities/ses/template_files/text/intake_form_confirmation_fr.txt"
43+
},
44+
{
45+
"HtmlPart": "app/utilities/ses/template_files/compiled/matches_available_en.html",
46+
"SubjectPart": "{{first_name}}, you have new matches - First Connection Peer Support Program",
47+
"TemplateName": "MatchesAvailableEn",
48+
"TextPart": "app/utilities/ses/template_files/text/matches_available_en.txt"
49+
},
50+
{
51+
"HtmlPart": "app/utilities/ses/template_files/compiled/matches_available_fr.html",
52+
"SubjectPart": "{{first_name}}, nouveaux jumelages – Programme de soutien par les pairs Premier contact",
53+
"TemplateName": "MatchesAvailableFr",
54+
"TextPart": "app/utilities/ses/template_files/text/matches_available_fr.txt"
55+
},
56+
{
57+
"HtmlPart": "app/utilities/ses/template_files/compiled/call_scheduled_en.html",
58+
"SubjectPart": "Call confirmed with {{match_name}} @ {{date}} {{time}} {{timezone}} - First Connection Peer Support Program",
59+
"TemplateName": "CallScheduledEn",
60+
"TextPart": "app/utilities/ses/template_files/text/call_scheduled_en.txt"
61+
},
62+
{
63+
"HtmlPart": "app/utilities/ses/template_files/compiled/call_scheduled_fr.html",
64+
"SubjectPart": "Confirmation de l'appel avec {{match_name}} le {{date}} à {{time}} {{timezone}} – Programme de soutien par les pairs Premier contact",
65+
"TemplateName": "CallScheduledFr",
66+
"TextPart": "app/utilities/ses/template_files/text/call_scheduled_fr.txt"
67+
},
68+
{
69+
"HtmlPart": "app/utilities/ses/template_files/compiled/participant_requested_new_times_en.html",
70+
"SubjectPart": "{{participant_name}} requested new times - First Connection Peer Support Program",
71+
"TemplateName": "ParticipantRequestedNewTimesEn",
72+
"TextPart": "app/utilities/ses/template_files/text/participant_requested_new_times_en.txt"
73+
},
74+
{
75+
"HtmlPart": "app/utilities/ses/template_files/compiled/participant_requested_new_times_fr.html",
76+
"SubjectPart": "Demande d'une autre plage horaire par {{participant_name}} – Programme de soutien par les pairs Premier contact",
77+
"TemplateName": "ParticipantRequestedNewTimesFr",
78+
"TextPart": "app/utilities/ses/template_files/text/participant_requested_new_times_fr.txt"
79+
},
80+
{
81+
"HtmlPart": "app/utilities/ses/template_files/compiled/volunteer_accepted_new_times_en.html",
82+
"SubjectPart": "{{volunteer_name}} confirmed your new time @ {{date}} {{time}} {{timezone}} - First Connection Peer Support Program",
83+
"TemplateName": "VolunteerAcceptedNewTimesEn",
84+
"TextPart": "app/utilities/ses/template_files/text/volunteer_accepted_new_times_en.txt"
85+
},
86+
{
87+
"HtmlPart": "app/utilities/ses/template_files/compiled/volunteer_accepted_new_times_fr.html",
88+
"SubjectPart": "Confirmation de la nouvelle plage horaire, le {{date}} à {{time}} {{timezone}}, par {{volunteer_name}} – Programme de soutien par les pairs Premier contact",
89+
"TemplateName": "VolunteerAcceptedNewTimesFr",
90+
"TextPart": "app/utilities/ses/template_files/text/volunteer_accepted_new_times_fr.txt"
2591
}
2692
]

0 commit comments

Comments
 (0)