-
-
{{ error_title|default("Error") }}
-
{{ error_message|default("An error occurred. Please try again.") }}
-
-
+
+
+
+
Oops! Something went wrong
+
{{ error }}
+
+
{% endblock %}
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
new file mode 100644
index 0000000..84095a6
--- /dev/null
+++ b/app/utils/__init__.py
@@ -0,0 +1 @@
+# Utils package initialization
diff --git a/app/utils/mail.py b/app/utils/mail.py
new file mode 100644
index 0000000..1856bab
--- /dev/null
+++ b/app/utils/mail.py
@@ -0,0 +1,199 @@
+"""
+Email utility module for LeagueLedger using FastAPI-Mail
+"""
+import os
+from pathlib import Path
+from typing import List, Dict, Any, Optional
+from fastapi import BackgroundTasks
+from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
+from pydantic import EmailStr
+from dotenv import load_dotenv
+
+# Load environment variables if not already loaded
+load_dotenv()
+
+# Configure email connection
+mail_config = ConnectionConfig(
+ MAIL_USERNAME=os.getenv("MAIL_USERNAME"),
+ MAIL_PASSWORD=os.getenv("MAIL_PASSWORD"),
+ MAIL_FROM=os.getenv("MAIL_FROM"),
+ MAIL_PORT=int(os.getenv("MAIL_PORT", 587)),
+ MAIL_SERVER=os.getenv("MAIL_SERVER"),
+ MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "LeagueLedger"),
+ MAIL_STARTTLS=os.getenv("MAIL_STARTTLS", "True").lower() in ("true", "1", "t"),
+ MAIL_SSL_TLS=os.getenv("MAIL_SSL_TLS", "False").lower() in ("true", "1", "t"),
+ USE_CREDENTIALS=os.getenv("MAIL_USE_CREDENTIALS", "True").lower() in ("true", "1", "t"),
+ VALIDATE_CERTS=os.getenv("MAIL_VALIDATE_CERTS", "True").lower() in ("true", "1", "t"),
+ TEMPLATE_FOLDER=Path(__file__).parent.parent / 'templates' / 'email',
+)
+
+# Create FastMail instance
+mail = FastMail(mail_config)
+
+
+async def send_email(
+ recipients: List[EmailStr],
+ subject: str,
+ body: str,
+ template_name: Optional[str] = None,
+ template_body: Optional[Dict[str, Any]] = None,
+ background_tasks: Optional[BackgroundTasks] = None,
+ subtype: MessageType = MessageType.html,
+ cc: Optional[List[EmailStr]] = None,
+ bcc: Optional[List[EmailStr]] = None,
+ attachments: Optional[List] = None,
+ headers: Optional[Dict[str, str]] = None,
+) -> None:
+ """
+ Send an email using FastAPI-Mail
+
+ Args:
+ recipients: List of recipient email addresses
+ subject: Email subject
+ body: Email body content (used if template_name is None)
+ template_name: Optional name of the template file in the TEMPLATE_FOLDER
+ template_body: Optional dictionary of template variables
+ background_tasks: Optional BackgroundTasks for sending email in background
+ subtype: Message type (html or plain)
+ cc: Optional list of CC recipients
+ bcc: Optional list of BCC recipients
+ attachments: Optional list of attachments
+ headers: Optional custom email headers
+ """
+ # Create message schema with empty lists for optional parameters to prevent validation errors
+ message = MessageSchema(
+ subject=subject,
+ recipients=recipients,
+ body=body if not template_name else None,
+ template_body=template_body,
+ subtype=subtype,
+ cc=cc or [], # Use empty list if None
+ bcc=bcc or [], # Use empty list if None
+ attachments=attachments or [], # Use empty list if None
+ headers=headers,
+ )
+
+ # Send email
+ try:
+ if background_tasks:
+ if template_name:
+ background_tasks.add_task(mail.send_message, message, template_name=template_name)
+ else:
+ background_tasks.add_task(mail.send_message, message)
+ else:
+ if template_name:
+ await mail.send_message(message, template_name=template_name)
+ else:
+ await mail.send_message(message)
+ except Exception as e:
+ # Log the error but don't crash the application
+ print(f"Error sending email: {str(e)}")
+ # In a production app, you would use a proper logging system
+
+
+async def send_password_reset_email(
+ email: EmailStr,
+ username: str,
+ reset_token: str,
+ background_tasks: Optional[BackgroundTasks] = None,
+) -> None:
+ """
+ Send password reset email
+
+ Args:
+ email: Recipient email address
+ username: User's username
+ reset_token: Password reset token
+ background_tasks: Optional BackgroundTasks for sending in background
+ """
+ # Base URL for the application (should be configured in environment vars)
+ base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
+ reset_link = f"{base_url}/auth/reset-password?token={reset_token}"
+
+ # Template data
+ template_data = {
+ "username": username,
+ "reset_link": reset_link,
+ "support_email": os.getenv("MAIL_FROM", "support@leagueledger.net"),
+ "base_url": base_url,
+ }
+
+ # Send email
+ await send_email(
+ recipients=[email],
+ subject="Password Reset - LeagueLedger",
+ body="", # Empty as we're using a template
+ template_name="password_reset.html",
+ template_body=template_data,
+ background_tasks=background_tasks,
+ )
+
+
+async def send_verification_email(
+ email: EmailStr,
+ username: str,
+ verification_token: str,
+ background_tasks: Optional[BackgroundTasks] = None,
+) -> None:
+ """
+ Send email verification link
+
+ Args:
+ email: Recipient email address
+ username: User's username
+ verification_token: Email verification token
+ background_tasks: Optional BackgroundTasks for sending in background
+ """
+ # Base URL for the application
+ base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
+ verification_link = f"{base_url}/auth/verify-email?token={verification_token}"
+
+ # Template data
+ template_data = {
+ "username": username,
+ "verification_link": verification_link,
+ "base_url": base_url,
+ }
+
+ # Send email
+ await send_email(
+ recipients=[email],
+ subject="Verify Your Email - LeagueLedger",
+ body="", # Empty as we're using a template
+ template_name="email_verification.html",
+ template_body=template_data,
+ background_tasks=background_tasks,
+ )
+
+
+async def send_welcome_email(
+ email: EmailStr,
+ username: str,
+ background_tasks: Optional[BackgroundTasks] = None,
+) -> None:
+ """
+ Send welcome email to new users
+
+ Args:
+ email: Recipient email address
+ username: User's username
+ background_tasks: Optional BackgroundTasks for sending in background
+ """
+ # Get base URL from environment variables
+ base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
+
+ # Template data
+ template_data = {
+ "username": username,
+ "base_url": base_url,
+ }
+
+ # Send email
+ await send_email(
+ recipients=[email],
+ subject="Welcome to LeagueLedger!",
+ body="", # Empty as we're using a template
+ template_name="welcome.html",
+ template_body=template_data,
+ background_tasks=background_tasks,
+ )
diff --git a/app/views/auth.py b/app/views/auth.py
index 8d6ad34..56d3081 100644
--- a/app/views/auth.py
+++ b/app/views/auth.py
@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
+from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
@@ -8,13 +8,14 @@ import uuid
import re
from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND
from sqlalchemy.orm import Session
-from datetime import datetime
+from datetime import datetime, timedelta
from ..db import get_db
from ..models import User
from ..auth.oauth import authentik_oauth
from ..templates_config import templates
from ..security import verify_password, get_password_hash
+from ..utils.mail import send_password_reset_email
router = APIRouter(prefix="/auth", tags=["Auth"])
@@ -52,15 +53,29 @@ async def login_post(
error = "Invalid password"
elif not user.is_active:
error = "This account has been deactivated"
-
- # If there was an error, re-render the login page
- if error:
+ elif not user.is_verified and not user.is_oauth_user:
+ # For unverified users, show special error message with option to resend verification
+ # Pass user ID in the template to enable resending verification email
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
- "error": error,
+ "error": "Please verify your email address before logging in",
"show_oauth": True,
+ "oauth_provider_name": "Authentik",
+ "unverified_user_id": user.id,
+ "unverified_email": user.email
+ }
+ )
+
+ # If any error was detected, return to the login page with the error message
+ if error:
+ return templates.TemplateResponse(
+ "auth/login.html",
+ {
+ "request": request,
+ "error": error,
+ "show_oauth": True,
"oauth_provider_name": "Authentik"
}
)
@@ -95,6 +110,7 @@ async def register_page(request: Request, error: Optional[str] = None):
@router.post("/register", response_class=HTMLResponse)
async def register_post(
request: Request,
+ background_tasks: BackgroundTasks,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
@@ -102,19 +118,186 @@ async def register_post(
db: Session = Depends(get_db)
):
"""Handle registration form submission"""
- # This is a placeholder - implement real registration logic here
+ # Validate passwords match
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "Passwords do not match"}
)
+
+ # Validate password strength
+ password_validation_error = validate_password_strength(password)
+ if password_validation_error:
+ return templates.TemplateResponse(
+ "auth/register.html",
+ {"request": request, "error": password_validation_error}
+ )
+
+ # Check if username already exists
+ if db.query(User).filter(User.username == username).first():
+ return templates.TemplateResponse(
+ "auth/register.html",
+ {"request": request, "error": "Username already taken"}
+ )
+
+ # Check if email already exists
+ if db.query(User).filter(User.email == email).first():
+ return templates.TemplateResponse(
+ "auth/register.html",
+ {"request": request, "error": "Email already registered"}
+ )
+
+ try:
+ # Create the user with verification token
+ verification_token = secrets.token_urlsafe(32)
+ expiration = datetime.utcnow() + timedelta(hours=24)
+
+ new_user = User(
+ username=username,
+ email=email,
+ hashed_password=get_password_hash(password),
+ is_verified=False,
+ verification_token=verification_token,
+ verification_token_expires_at=expiration,
+ last_verification_email_sent=datetime.utcnow() # Add this line
+ )
+
+ db.add(new_user)
+ db.commit()
+ db.refresh(new_user)
+
+ # Send verification email
+ try:
+ from ..utils.mail import send_verification_email
+ await send_verification_email(
+ email=email,
+ username=username,
+ verification_token=verification_token,
+ background_tasks=background_tasks
+ )
+ except Exception as e:
+ print(f"Failed to send verification email: {str(e)}")
+ # We'll show success anyway, but log the error
+
+ # Return success template
+ return templates.TemplateResponse(
+ "auth/registration_success.html",
+ {"request": request, "email_verification_required": True}
+ )
+ except Exception as e:
+ print(f"Registration error: {str(e)}")
+ return templates.TemplateResponse(
+ "auth/register.html",
+ {"request": request, "error": "An error occurred during registration"}
+ )
- # Check username and email uniqueness, then create user
+@router.get("/verify-email", response_class=HTMLResponse)
+async def verify_email(
+ request: Request,
+ token: str,
+ db: Session = Depends(get_db)
+):
+ """Verify user email address with verification token"""
+ # Find user by verification token
+ user = db.query(User).filter(User.verification_token == token).first()
+
+ # Check if token exists and hasn't expired
+ if not user or not user.verification_token_expires_at or user.verification_token_expires_at < datetime.utcnow():
+ return templates.TemplateResponse(
+ "auth/verification_error.html",
+ {"request": request, "error": "Invalid or expired verification link"}
+ )
+
+ # Mark user as verified and clear verification token
+ user.is_verified = True
+ user.verification_token = None
+ user.verification_token_expires_at = None
+ db.commit()
+
+ # Send welcome email in the background
+ try:
+ from ..utils.mail import send_welcome_email
+ background_tasks = BackgroundTasks()
+ background_tasks.add_task(
+ send_welcome_email,
+ email=user.email,
+ username=user.username
+ )
+ except Exception as e:
+ print(f"Failed to queue welcome email: {str(e)}")
+
+ # Redirect to login page with success message
return templates.TemplateResponse(
- "auth/registration_success.html",
- {"request": request}
+ "auth/verification_success.html",
+ {"request": request, "username": user.username}
)
+@router.get("/resend-verification", response_class=HTMLResponse)
+async def resend_verification(
+ request: Request,
+ user_id: int,
+ db: Session = Depends(get_db),
+ background_tasks: BackgroundTasks = BackgroundTasks()
+):
+ """Resend verification email with cooldown period"""
+ # Get the user
+ user = db.query(User).filter(User.id == user_id).first()
+
+ if not user:
+ return RedirectResponse(
+ "/auth/login?error=User+not+found",
+ status_code=HTTP_303_SEE_OTHER
+ )
+
+ # Check if user is already verified
+ if user.is_verified:
+ return RedirectResponse(
+ "/auth/login?message=Your+account+is+already+verified.+Please+log+in.",
+ status_code=HTTP_303_SEE_OTHER
+ )
+
+ # Check cooldown period (1 hour)
+ if user.last_verification_email_sent and (datetime.utcnow() - user.last_verification_email_sent) < timedelta(hours=1):
+ # Calculate time remaining in cooldown
+ time_since_last_email = datetime.utcnow() - user.last_verification_email_sent
+ minutes_remaining = max(0, 60 - int(time_since_last_email.total_seconds() / 60))
+
+ return RedirectResponse(
+ f"/auth/login?error=Verification+email+was+recently+sent.+Please+wait+{minutes_remaining}+minutes+before+requesting+another+one.",
+ status_code=HTTP_303_SEE_OTHER
+ )
+
+ # Generate new verification token
+ verification_token = secrets.token_urlsafe(32)
+ expiration = datetime.utcnow() + timedelta(hours=24)
+
+ # Update user record
+ user.verification_token = verification_token
+ user.verification_token_expires_at = expiration
+ user.last_verification_email_sent = datetime.utcnow()
+ db.commit()
+
+ # Send verification email
+ try:
+ from ..utils.mail import send_verification_email
+ await send_verification_email(
+ email=user.email,
+ username=user.username,
+ verification_token=verification_token,
+ background_tasks=background_tasks
+ )
+
+ return RedirectResponse(
+ "/auth/login?message=Verification+email+has+been+resent.+Please+check+your+inbox.",
+ status_code=HTTP_303_SEE_OTHER
+ )
+ except Exception as e:
+ print(f"Failed to send verification email: {str(e)}")
+ return RedirectResponse(
+ "/auth/login?error=Failed+to+send+verification+email.+Please+try+again+later.",
+ status_code=HTTP_303_SEE_OTHER
+ )
+
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Start the OAuth login flow"""
@@ -228,7 +411,7 @@ async def logout(request: Request):
return RedirectResponse("/", status_code=HTTP_303_SEE_OTHER)
@router.get("/profile", response_class=HTMLResponse)
-async def profile_page(request: Request):
+async def profile_page(request: Request, db: Session = Depends(get_db)):
"""User profile page"""
# Get the user ID from the session
user_id = request.session.get("user_id")
@@ -236,15 +419,13 @@ async def profile_page(request: Request):
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
- # Mock user data - in a real app, you'd fetch this from the database
- user = {
- "id": user_id,
- "username": request.session.get("username", "User"),
- "email": "user@example.com",
- "is_admin": request.session.get("is_admin", False),
- "created_at": "2023-01-01 12:00:00",
- "picture": None
- }
+ # Fetch the actual user data from the database
+ user = db.query(User).filter(User.id == user_id).first()
+
+ if not user:
+ # If user doesn't exist in the database but has a session, clear the session
+ request.session.clear()
+ return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"auth/profile.html",
@@ -330,6 +511,141 @@ async def change_password_post(
status_code=HTTP_303_SEE_OTHER
)
+@router.get("/forgot-password", response_class=HTMLResponse)
+async def forgot_password_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
+ """Forgot password page"""
+ return templates.TemplateResponse(
+ "auth/forgot_password.html",
+ {"request": request, "error": error, "message": message}
+ )
+
+@router.post("/forgot-password", response_class=HTMLResponse)
+async def forgot_password_post(
+ request: Request,
+ background_tasks: BackgroundTasks,
+ email: str = Form(...),
+ db: Session = Depends(get_db)
+):
+ """Handle forgot password form submission"""
+ try:
+ # Find user by email
+ user = db.query(User).filter(User.email == email).first()
+
+ # Always show success message even if email doesn't exist (security best practice)
+ if not user:
+ return templates.TemplateResponse(
+ "auth/forgot_password.html",
+ {
+ "request": request,
+ "message": "If your email is in our system, you will receive a password reset link shortly."
+ }
+ )
+
+ # Generate reset token
+ reset_token = secrets.token_urlsafe(32)
+ user.reset_token = reset_token
+ user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=24)
+ db.commit()
+
+ try:
+ # Send reset email
+ await send_password_reset_email(
+ email=user.email,
+ username=user.username,
+ reset_token=reset_token,
+ background_tasks=background_tasks,
+ )
+ except Exception as e:
+ print(f"Failed to send password reset email: {str(e)}")
+ # We don't show this error to the user for security reasons
+ # In a production app, you would log this error properly
+
+ return templates.TemplateResponse(
+ "auth/forgot_password.html",
+ {
+ "request": request,
+ "message": "If your email is in our system, you will receive a password reset link shortly."
+ }
+ )
+ except Exception as e:
+ print(f"Password reset error: {str(e)}")
+ return templates.TemplateResponse(
+ "auth/forgot_password.html",
+ {
+ "request": request,
+ "error": "An error occurred. Please try again later."
+ }
+ )
+
+@router.get("/reset-password", response_class=HTMLResponse)
+async def reset_password_page(
+ request: Request,
+ token: str,
+ error: Optional[str] = None,
+ db: Session = Depends(get_db)
+):
+ """Reset password page"""
+ # Validate token
+ user = db.query(User).filter(User.reset_token == token).first()
+
+ # Check if token exists and hasn't expired
+ if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.utcnow():
+ return templates.TemplateResponse(
+ "auth/reset_password_error.html",
+ {"request": request, "error": "Invalid or expired reset token."}
+ )
+
+ return templates.TemplateResponse(
+ "auth/reset_password.html",
+ {"request": request, "token": token, "error": error}
+ )
+
+@router.post("/reset-password", response_class=HTMLResponse)
+async def reset_password_post(
+ request: Request,
+ token: str = Form(...),
+ new_password: str = Form(...),
+ confirm_password: str = Form(...),
+ db: Session = Depends(get_db)
+):
+ """Handle reset password form submission"""
+ # Validate token
+ user = db.query(User).filter(User.reset_token == token).first()
+
+ # Check if token exists and hasn't expired
+ if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.utcnow():
+ return templates.TemplateResponse(
+ "auth/reset_password_error.html",
+ {"request": request, "error": "Invalid or expired reset token."}
+ )
+
+ # Validate passwords
+ if new_password != confirm_password:
+ return templates.TemplateResponse(
+ "auth/reset_password.html",
+ {"request": request, "token": token, "error": "Passwords do not match."}
+ )
+
+ # Server-side password strength validation
+ password_validation_error = validate_password_strength(new_password)
+ if password_validation_error:
+ return templates.TemplateResponse(
+ "auth/reset_password.html",
+ {"request": request, "token": token, "error": password_validation_error}
+ )
+
+ # Update password and clear reset token
+ user.hashed_password = get_password_hash(new_password)
+ user.reset_token = None
+ user.reset_token_expires_at = None # Clear the token after use
+ db.commit()
+
+ # Redirect to login page with success message
+ return RedirectResponse(
+ "/auth/login?message=Password+has+been+reset+successfully.+Please+login+with+your+new+password.",
+ status_code=HTTP_303_SEE_OTHER
+ )
+
def validate_password_strength(password: str) -> Optional[str]:
"""
Validates password strength based on the following criteria:
diff --git a/docker-compose.yml b/docker-compose.yml
index c93cf25..b50aba8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,7 +34,19 @@ services:
DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass"
PYTHONUNBUFFERED: "1"
- LEAGUELEDGER_BASE_URL: "https://rover.leagueledger.net" # Base URL for QR codes
+ # Use environment variable from .env instead of hard-coding
+ LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
+ # Email configuration for Mailhog
+ MAIL_USERNAME: ""
+ MAIL_PASSWORD: ""
+ MAIL_FROM: "noreply@leagueledger.net"
+ MAIL_FROM_NAME: "LeagueLedger"
+ MAIL_PORT: 1025
+ MAIL_SERVER: "mailpit"
+ MAIL_STARTTLS: "False"
+ MAIL_SSL_TLS: "False"
+ MAIL_USE_CREDENTIALS: "False"
+ MAIL_VALIDATE_CERTS: "False"
command: uvicorn app.main:app --host 0.0.0.0 --reload
ports:
- "8000:8000"
@@ -55,4 +67,17 @@ services:
ports:
- "8001:80"
+ mailpit:
+ image: axllent/mailpit # Updated image name
+ container_name: pubquiz_mailpit
+ restart: unless-stopped
+ ports:
+ - "8025:8025" # Web UI
+ - "1025:1025" # SMTP Server
+ environment:
+ MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart)
+ MH_UI_WEB_PATH: "/" # Base path for the web UI
+ networks:
+ - default
+
# No persistent volumes defined - database will reset when container stops
diff --git a/docs/email_testing.md b/docs/email_testing.md
new file mode 100644
index 0000000..feecd3a
--- /dev/null
+++ b/docs/email_testing.md
@@ -0,0 +1,55 @@
+# Email Testing with Mailhog
+
+This project is configured to use Mailhog for email testing during development. Mailhog provides a fake SMTP server that captures all outgoing emails and displays them in a web interface instead of actually sending them.
+
+## How it Works
+
+When running the application in the Docker development environment, all emails are sent to the Mailhog container instead of real recipients. This allows you to test email functionality without worrying about sending actual emails.
+
+## Viewing Captured Emails
+
+1. Start the Docker containers using:
+ ```
+ docker-compose up -d
+ ```
+
+2. Access the Mailhog web interface at:
+ ```
+ http://localhost:8025
+ ```
+
+3. Any emails sent by the application will appear in this interface, where you can:
+ - View the email content (HTML and text versions)
+ - See all recipients, headers, and attachments
+ - Release emails to actually be delivered (if configured)
+ - Delete emails
+
+## Configuration
+
+The Mailhog SMTP server is configured with:
+- Host: `mailhog`
+- Port: `1025`
+- No authentication required
+
+These settings are already configured in the `.env` file and the Docker environment variables.
+
+## Switching to Production Email
+
+When deploying to production, update the SMTP configuration in the `.env` file to use your actual email service provider. There are commented-out production settings in the `.env` file that you can uncomment and configure.
+
+## Troubleshooting
+
+If emails aren't appearing in the Mailhog interface:
+
+1. Make sure all containers are running:
+ ```
+ docker-compose ps
+ ```
+
+2. Check the logs for errors:
+ ```
+ docker-compose logs app
+ docker-compose logs mailhog
+ ```
+
+3. Verify that the application is using the correct SMTP settings by checking the environment variables passed to the app container.
diff --git a/requirements.txt b/requirements.txt
index c3b850d..fc0b859 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -30,6 +30,9 @@ email-validator>=2.0.0
pydantic>=2.3.0
qrcode>=7.4.2
+# Email support
+fastapi-mail>=1.4.2
+
# Image processing library for QR code generation
Pillow>=9.0.0