Skip to content

Used Jinja2 templates to generate the reset email #61

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

Merged
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
echo "DB_NAME=test_db" >> $GITHUB_ENV
echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV
echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV

- name: Verify environment variables
run: |
Expand All @@ -63,7 +64,8 @@ jobs:
[ -n "$DB_HOST" ] && \
[ -n "$DB_PORT" ] && \
[ -n "$DB_NAME" ] && \
[ -n "$SECRET_KEY" ]
[ -n "$SECRET_KEY" ] && \
[ -n "$RESEND_API_KEY" ]

- name: Run type checking with mypy
run: poetry run mypy .
Expand Down
10 changes: 10 additions & 0 deletions docs/customization.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri

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.

#### Email templating

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.

Here's how the default password reset email template looks:

![Default Password Reset Email Template](static/reset_email.png)

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).

### Writing type annotated code

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.
Expand Down
10 changes: 10 additions & 0 deletions docs/static/documentation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri

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.

#### Email templating

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.

Here's how the default password reset email template looks:

![Default Password Reset Email Template](static/reset_email.png)

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).

### Writing type annotated code

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.
Expand Down
Binary file added docs/static/reset_email.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions templates/emails/base_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% from 'components/logo.html' import render_logo %}

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block email_title %}{% endblock %}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<!-- Header with branding -->
<div style="display: inline-block; vertical-align: middle;">
{{ render_logo(width=40, height=40) }}
<span style="display: inline-block; vertical-align: middle; font-size: 24px; margin-left: 10px;">FastAPI-Jinja2-Postgres Webapp</span>
</div>

<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">

{% block email_content %}{% endblock %}

<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">

<p style="font-size: 12px; color: #666;">
This is an automated message, please do not reply directly to this email.
{% block email_footer %}
{% endblock %}
</p>
</div>
</body>
</html>
19 changes: 19 additions & 0 deletions templates/emails/reset_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% extends "emails/base_email.html" %}

{% block email_title %}Password Reset{% endblock %}

{% block email_content %}
<h2>Password Reset Request</h2>
<p>Hello,</p>
<p>We received a request to reset your password. If you didn't make this request, you can safely ignore this email.</p>
<p style="margin: 25px 0;">
<a href="{{ reset_url }}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reset Your Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all;">{{ reset_url }}</p>
<p>This link will expire in 24 hours.</p>
{% endblock %}

{% block email_footer %}
If you didn't request this password reset, please ignore this email or contact support if you have concerns.
{% endblock %}
6 changes: 4 additions & 2 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from unittest.mock import patch
import resend
from urllib.parse import urlparse, parse_qs
from html import unescape

from main import app
from utils.models import User, PasswordResetToken
Expand Down Expand Up @@ -321,12 +322,13 @@ def test_password_reset_email_url(unauth_client: TestClient, session: Session, t
mock_resend_send.assert_called_once()
call_args = mock_resend_send.call_args[0][0]
html_content = call_args["html"]
print(html_content)

# Extract URL from HTML
import re
url_match = re.search(r'href=[\'"]([^\'"]*)[\'"]', html_content)
url_match = re.search(r'<a[^>]*href=[\'"]([^\'"]*)[\'"]', html_content)
assert url_match is not None
reset_url = url_match.group(1)
reset_url = unescape(url_match.group(1))

# Parse and verify the URL
parsed = urlparse(reset_url)
Expand Down
28 changes: 21 additions & 7 deletions utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@
from bcrypt import gensalt, hashpw, checkpw
from datetime import UTC, datetime, timedelta
from typing import Optional
from jinja2.environment import Template
from fastapi.templating import Jinja2Templates
from fastapi import Depends, Cookie, HTTPException, status
from utils.db import get_session
from utils.models import User, Role, PasswordResetToken

load_dotenv()
logger = logging.getLogger("uvicorn.error")
resend.api_key = os.environ["RESEND_API_KEY"]

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())


# --- Constants ---


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


def send_reset_email(email: str, session: Session):
def send_reset_email(email: str, session: Session) -> None:
# Check for an existing unexpired token
user = session.exec(select(User).where(
user: Optional[User] = session.exec(select(User).where(
User.email == email
)).first()
if user:
Expand All @@ -314,17 +321,24 @@ def send_reset_email(email: str, session: Session):
return

# Generate a new token
token = str(uuid.uuid4())
reset_token = PasswordResetToken(user_id=user.id, token=token)
token: str = str(uuid.uuid4())
reset_token: PasswordResetToken = PasswordResetToken(
user_id=user.id, token=token)
session.add(reset_token)

try:
reset_url = generate_password_reset_url(email, token)
reset_url: str = generate_password_reset_url(email, token)

# Render the email template
template: Template = templates.get_template(
"emails/reset_email.html")
html_content: str = template.render({"reset_url": reset_url})

params: resend.Emails.SendParams = {
"from": "[email protected]",
"to": [email],
"subject": "Password Reset Request",
"html": f"<p>Click <a href='{reset_url}'>here</a> to reset your password.</p>",
"html": html_content,
}

sent_email: resend.Email = resend.Emails.send(params)
Expand Down
Loading