Skip to content

Commit 0d01c92

Browse files
Merge pull request #61 from Promptly-Technologies-LLC/25-use-a-template-for-styling-password-reset-emails
Used Jinja2 templates to generate the reset email
2 parents ef35a9c + e763843 commit 0d01c92

File tree

8 files changed

+98
-10
lines changed

8 files changed

+98
-10
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ jobs:
5454
echo "DB_NAME=test_db" >> $GITHUB_ENV
5555
echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
5656
echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV
57+
echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV
5758
5859
- name: Verify environment variables
5960
run: |
@@ -63,7 +64,8 @@ jobs:
6364
[ -n "$DB_HOST" ] && \
6465
[ -n "$DB_PORT" ] && \
6566
[ -n "$DB_NAME" ] && \
66-
[ -n "$SECRET_KEY" ]
67+
[ -n "$SECRET_KEY" ] && \
68+
[ -n "$RESEND_API_KEY" ]
6769
6870
- name: Run type checking with mypy
6971
run: poetry run mypy .

docs/customization.qmd

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri
138138

139139
Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching.
140140

141+
#### Email templating
142+
143+
Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling.
144+
145+
Here's how the default password reset email template looks:
146+
147+
![Default Password Reset Email Template](static/reset_email.png)
148+
149+
The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template).
150+
141151
### Writing type annotated code
142152

143153
Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads.

docs/static/documentation.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri
790790

791791
Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching.
792792

793+
#### Email templating
794+
795+
Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling.
796+
797+
Here's how the default password reset email template looks:
798+
799+
![Default Password Reset Email Template](static/reset_email.png)
800+
801+
The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template).
802+
793803
### Writing type annotated code
794804

795805
Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads.

docs/static/reset_email.png

55.4 KB
Loading

templates/emails/base_email.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{% from 'components/logo.html' import render_logo %}
2+
3+
<!DOCTYPE html>
4+
<html>
5+
<head>
6+
<meta charset="utf-8">
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
8+
<title>{% block email_title %}{% endblock %}</title>
9+
</head>
10+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
11+
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
12+
<!-- Header with branding -->
13+
<div style="display: inline-block; vertical-align: middle;">
14+
{{ render_logo(width=40, height=40) }}
15+
<span style="display: inline-block; vertical-align: middle; font-size: 24px; margin-left: 10px;">FastAPI-Jinja2-Postgres Webapp</span>
16+
</div>
17+
18+
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
19+
20+
{% block email_content %}{% endblock %}
21+
22+
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
23+
24+
<p style="font-size: 12px; color: #666;">
25+
This is an automated message, please do not reply directly to this email.
26+
{% block email_footer %}
27+
{% endblock %}
28+
</p>
29+
</div>
30+
</body>
31+
</html>

templates/emails/reset_email.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% extends "emails/base_email.html" %}
2+
3+
{% block email_title %}Password Reset{% endblock %}
4+
5+
{% block email_content %}
6+
<h2>Password Reset Request</h2>
7+
<p>Hello,</p>
8+
<p>We received a request to reset your password. If you didn't make this request, you can safely ignore this email.</p>
9+
<p style="margin: 25px 0;">
10+
<a href="{{ reset_url }}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reset Your Password</a>
11+
</p>
12+
<p>Or copy and paste this link into your browser:</p>
13+
<p style="word-break: break-all;">{{ reset_url }}</p>
14+
<p>This link will expire in 24 hours.</p>
15+
{% endblock %}
16+
17+
{% block email_footer %}
18+
If you didn't request this password reset, please ignore this email or contact support if you have concerns.
19+
{% endblock %}

tests/test_authentication.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import patch
77
import resend
88
from urllib.parse import urlparse, parse_qs
9+
from html import unescape
910

1011
from main import app
1112
from utils.models import User, PasswordResetToken
@@ -321,12 +322,13 @@ def test_password_reset_email_url(unauth_client: TestClient, session: Session, t
321322
mock_resend_send.assert_called_once()
322323
call_args = mock_resend_send.call_args[0][0]
323324
html_content = call_args["html"]
325+
print(html_content)
324326

325327
# Extract URL from HTML
326328
import re
327-
url_match = re.search(r'href=[\'"]([^\'"]*)[\'"]', html_content)
329+
url_match = re.search(r'<a[^>]*href=[\'"]([^\'"]*)[\'"]', html_content)
328330
assert url_match is not None
329-
reset_url = url_match.group(1)
331+
reset_url = unescape(url_match.group(1))
330332

331333
# Parse and verify the URL
332334
parsed = urlparse(reset_url)

utils/auth.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,24 @@
1212
from bcrypt import gensalt, hashpw, checkpw
1313
from datetime import UTC, datetime, timedelta
1414
from typing import Optional
15+
from jinja2.environment import Template
16+
from fastapi.templating import Jinja2Templates
1517
from fastapi import Depends, Cookie, HTTPException, status
1618
from utils.db import get_session
1719
from utils.models import User, Role, PasswordResetToken
1820

1921
load_dotenv()
20-
logger = logging.getLogger("uvicorn.error")
22+
resend.api_key = os.environ["RESEND_API_KEY"]
23+
24+
logger = logging.getLogger(__name__)
25+
logger.setLevel(logging.DEBUG)
26+
logger.addHandler(logging.StreamHandler())
2127

2228

2329
# --- Constants ---
2430

2531

32+
templates = Jinja2Templates(directory="templates")
2633
SECRET_KEY = os.getenv("SECRET_KEY")
2734
ALGORITHM = "HS256"
2835
ACCESS_TOKEN_EXPIRE_MINUTES = 30
@@ -294,9 +301,9 @@ def generate_password_reset_url(email: str, token: str) -> str:
294301
return f"{base_url}/auth/reset_password?email={email}&token={token}"
295302

296303

297-
def send_reset_email(email: str, session: Session):
304+
def send_reset_email(email: str, session: Session) -> None:
298305
# Check for an existing unexpired token
299-
user = session.exec(select(User).where(
306+
user: Optional[User] = session.exec(select(User).where(
300307
User.email == email
301308
)).first()
302309
if user:
@@ -314,17 +321,24 @@ def send_reset_email(email: str, session: Session):
314321
return
315322

316323
# Generate a new token
317-
token = str(uuid.uuid4())
318-
reset_token = PasswordResetToken(user_id=user.id, token=token)
324+
token: str = str(uuid.uuid4())
325+
reset_token: PasswordResetToken = PasswordResetToken(
326+
user_id=user.id, token=token)
319327
session.add(reset_token)
320328

321329
try:
322-
reset_url = generate_password_reset_url(email, token)
330+
reset_url: str = generate_password_reset_url(email, token)
331+
332+
# Render the email template
333+
template: Template = templates.get_template(
334+
"emails/reset_email.html")
335+
html_content: str = template.render({"reset_url": reset_url})
336+
323337
params: resend.Emails.SendParams = {
324338
"from": "[email protected]",
325339
"to": [email],
326340
"subject": "Password Reset Request",
327-
"html": f"<p>Click <a href='{reset_url}'>here</a> to reset your password.</p>",
341+
"html": html_content,
328342
}
329343

330344
sent_email: resend.Email = resend.Emails.send(params)

0 commit comments

Comments
 (0)