diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af360f1..918cd90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: | @@ -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 . diff --git a/docs/customization.qmd b/docs/customization.qmd index 1f9a439..9cebc65 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -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: + + + +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. diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 34ef6a5..2669ef8 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -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: + + + +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. diff --git a/docs/static/reset_email.png b/docs/static/reset_email.png new file mode 100644 index 0000000..b93623b Binary files /dev/null and b/docs/static/reset_email.png differ diff --git a/templates/emails/base_email.html b/templates/emails/base_email.html new file mode 100644 index 0000000..2fc0570 --- /dev/null +++ b/templates/emails/base_email.html @@ -0,0 +1,31 @@ +{% from 'components/logo.html' import render_logo %} + + + +
+ + ++ This is an automated message, please do not reply directly to this email. + {% block email_footer %} + {% endblock %} +
+Hello,
+We received a request to reset your password. If you didn't make this request, you can safely ignore this email.
+ +Or copy and paste this link into your browser:
+{{ reset_url }}
+This link will expire in 24 hours.
+{% endblock %} + +{% block email_footer %} + If you didn't request this password reset, please ignore this email or contact support if you have concerns. +{% endblock %} \ No newline at end of file diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 58bcc04..3f76c79 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -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 @@ -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']*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) diff --git a/utils/auth.py b/utils/auth.py index e7de8c5..01fc8f4 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -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 @@ -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: @@ -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": "noreply@promptlytechnologies.com", "to": [email], "subject": "Password Reset Request", - "html": f"Click here to reset your password.
", + "html": html_content, } sent_email: resend.Email = resend.Emails.send(params)