feat: Enhance email templates and add plain text support for improved deliverability

This commit is contained in:
Christian Krakau-Louis
2025-04-17 22:22:09 +02:00
parent 6753444daf
commit 828719b318
13 changed files with 663 additions and 269 deletions
+10 -4
View File
@@ -55,10 +55,10 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl
## User Management & Profile Features
- [ ] **Profile Management**
- [ ] Update profile picture functionality
- [ ] Change username capability
- [ ] Account deletion process
- [ ] Profile privacy settings
- [x] Update profile picture functionality
- [x] Change username capability
- [x] Account deletion process
- [x] Profile privacy settings
- [ ] Social media integration
## Environment Variables & Configuration
@@ -159,3 +159,9 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl
- [ ] Add support for clustering/high availability
- [ ] Implement CDN for static assets
- [ ] Create backup and disaster recovery procedures
## Layout
- [ ] Change layout to use Tailwind CSS
- [ ] Change layout to use https://ui.shadcn.com/
- [ ] Make mail templates less spam-y
+17 -15
View File
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from passlib.context import CryptContext
import asyncio
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
from .db import SessionLocal, engine
@@ -26,17 +27,17 @@ def table_has_column(engine, table_name, column_name):
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def init_db():
async def init_db():
"""Initialize the database, applying migrations and seeding data."""
# First, create all tables if they don't exist
from .models import Base
Base.metadata.create_all(bind=engine)
# Then, run any needed migrations
run_migrations(engine)
await asyncio.to_thread(run_migrations, engine)
# Then proceed with seeding if needed
seed_db()
await asyncio.to_thread(seed_db)
# Initialize system settings if needed
init_system_settings()
await asyncio.to_thread(init_system_settings)
def init_system_settings():
"""Initialize the system settings table if it doesn't exist."""
@@ -173,23 +174,24 @@ def seed_db():
memberships = []
membership_data = [
# Quiz Wizards
(1, 1, True, 160), # Admin user is team admin of Quiz Wizards
(2, 1, True, 155),
(3, 1, False, 130),
(4, 1, False, 90),
(5, 1, False, 45),
(1, 1, True, True, 160), # Admin user is team admin AND captain of Quiz Wizards
(2, 1, True, True, 155), # John is also admin AND captain
(3, 1, False, False, 130),
(4, 1, False, False, 90),
(5, 1, False, False, 45),
# Trivia Titans
(2, 2, True, 150),
(1, 2, False, 145),
(2, 2, True, True, 150), # John is admin AND captain of Trivia Titans
(1, 2, False, False, 145),
# Beer Brainiacs
(3, 3, True, 120),
(3, 3, True, True, 120), # Sarah is admin AND captain of Beer Brainiacs
]
for user_id, team_id, is_admin, days_ago in membership_data:
for user_id, team_id, is_admin, is_captain, days_ago in membership_data:
membership_attrs = {
"user_id": user_id,
"team_id": team_id,
"is_admin": is_admin
"is_admin": is_admin,
"is_captain": is_captain # Added is_captain field
}
if has_joined_at:
membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago)
@@ -366,4 +368,4 @@ def seed_db():
db.close()
if __name__ == "__main__":
init_db()
asyncio.run(init_db())
+74 -6
View File
@@ -11,6 +11,11 @@ from starlette.middleware.authentication import AuthenticationMiddleware
from dotenv import load_dotenv
import logging
import contextlib
import time
import asyncio
import sqlalchemy
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy import text
from .db import engine, get_db
from . import models
@@ -52,10 +57,54 @@ app.add_middleware(
# Then add SessionMiddleware (last added = first executed)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Initialize database on startup
@app.on_event("startup")
async def startup_db_client():
logger.info("Starting database initialization")
# Function to check database connection
async def check_db_connection(max_retries=10, initial_retry_delay=1):
"""
Check database connection with retries and exponential backoff
Args:
max_retries: Maximum number of retry attempts
initial_retry_delay: Initial delay before first retry in seconds
Returns:
bool: True if connection succeeded, False otherwise
"""
retry_delay = initial_retry_delay
for attempt in range(1, max_retries + 1):
try:
# Try a simple query to test the connection
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
logger.info(f"Database connection successful on attempt {attempt}")
return True
except (OperationalError, ProgrammingError) as e:
if "Connection refused" in str(e) or "Can't connect" in str(e):
logger.warning(f"Database connection attempt {attempt}/{max_retries} failed: {str(e)}")
if attempt < max_retries:
logger.info(f"Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
# Exponential backoff with a maximum delay of 10 seconds
retry_delay = min(retry_delay * 2, 10)
else:
logger.error(f"Failed to connect to database after {max_retries} attempts")
return False
else:
# For other database errors, log and continue
logger.error(f"Database error: {str(e)}")
return False
except Exception as e:
logger.error(f"Unexpected error connecting to database: {str(e)}")
return False
return False
# Initialize database with schema and tables
async def initialize_database():
"""
Initialize the database schema and seed data
"""
try:
# Import models to ensure they're registered with Base before initialization
from . import models
@@ -63,10 +112,29 @@ async def startup_db_client():
# Initialize database (applies migrations and seeds data)
init_db()
logger.info("Database initialized and migrated successfully")
return True
except Exception as e:
logger.error(f"Database initialization error: {str(e)}")
# We continue even if there was an error, as the application might still work with partial functionality
return False
# Initialize database on startup
@app.on_event("startup")
async def startup_db_client():
logger.info("Starting database initialization")
# First, ensure database server is available with polling
max_retries = 15 # Try up to 15 times (with exponential backoff, this could be ~2 minutes)
connected = await check_db_connection(max_retries=max_retries)
if connected:
# Once connected, initialize the database
success = await initialize_database()
if success:
logger.info("Database setup completed successfully")
else:
logger.warning("Database initialization completed with errors")
else:
logger.error("Failed to connect to database, application may not function correctly")
# Mount static files
app.mount("/static", StaticFiles(directory="app/static"), name="static")
+56 -26
View File
@@ -12,62 +12,92 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
padding: 30px;
background-color: #ffffff;
}
.button-container {
margin: 25px 0;
text-align: center;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
padding: 12px 25px;
border-radius: 5px;
margin: 20px 0;
font-weight: 500;
}
.link-help {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
font-size: 14px;
}
.footer {
margin-top: 20px;
font-size: 12px;
padding: 20px;
text-align: center;
font-size: 12px;
color: #777;
border-top: 1px solid #eaeaea;
}
</style>
</head>
<body>
<div class="header">
<h1>Verify Your Email Address</h1>
</div>
<div class="container">
<div class="header">
<h1>Email Verification</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<div class="content">
<p>Hello {{ username }},</p>
<p>Thank you for registering with LeagueLedger! To complete your registration, please verify your email address by clicking the button below:</p>
<p>Thank you for creating an account with LeagueLedger, your pub quiz team tracking platform. We're excited to have you join our community!</p>
<p style="text-align: center;">
<a href="{{ verification_link }}" class="button">Verify Email</a>
</p>
<p>To ensure account security and complete your registration, please verify your email address by clicking the button below:</p>
<p>Or copy and paste this link into your browser:</p>
<p>{{ verification_link }}</p>
<div class="button-container">
<a href="{{ verification_link }}" class="button">Verify My Email</a>
</div>
<p>If you did not create an account with us, please ignore this email.</p>
<div class="link-help">
<p>If the button above doesn't work, you can copy and paste this link into your browser:</p>
<p><a href="{{ verification_link }}">Complete your verification</a></p>
</div>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<p>This verification step helps us keep your account secure and allows you to:</p>
<ul>
<li>Create or join teams</li>
<li>Track your quiz night scores</li>
<li>Earn points and achievements</li>
<li>Climb the leaderboards</li>
</ul>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
<p>If you did not create an account with LeagueLedger, please disregard this email.</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© 2025 LeagueLedger. All rights reserved.</p>
<p>This email was sent to verify your account registration.</p>
</div>
</div>
</body>
</html>
+85 -29
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Join Request Response - LeagueLedger</title>
<title>Team Join Request Update - LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -12,63 +12,119 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
padding: 30px;
background-color: #ffffff;
}
.status-message {
margin: 20px 0;
padding: 15px;
border-radius: 5px;
}
.status-approved {
background-color: #e8f5e9;
border-left: 4px solid #2D7738;
}
.status-denied {
background-color: #f5f5f5;
border-left: 4px solid #9e9e9e;
}
.button-container {
margin: 25px 0;
text-align: center;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
padding: 12px 25px;
border-radius: 5px;
font-weight: 500;
}
.next-steps {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 20px;
font-size: 12px;
padding: 20px;
text-align: center;
font-size: 12px;
color: #777;
border-top: 1px solid #eaeaea;
}
</style>
</head>
<body>
<div class="header">
<h1>Team Join Request {{ "Approved" if is_approved else "Denied" }}</h1>
</div>
<div class="container">
<div class="header">
<h1>Team Request {{ "Approved" if is_approved else "Status Update" }}</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<div class="content">
<p>Hello {{ username }},</p>
{% if is_approved %}
<p>Good news! Your request to join <strong>{{ team_name }}</strong> has been approved. You are now a member of the team.</p>
{% else %}
<p>We regret to inform you that your request to join <strong>{{ team_name }}</strong> has been denied.</p>
{% endif %}
{% if is_approved %}
<div class="status-message status-approved">
<h2>Welcome to the team!</h2>
<p>Your request to join <strong>{{ team_name }}</strong> has been approved. You are now officially a member of the team.</p>
</div>
<p>You can view your teams by visiting your dashboard:</p>
<p>As a team member, you can now:</p>
<ul>
<li>Participate in team events</li>
<li>Contribute to your team's score</li>
<li>Earn points and achievements together</li>
<li>Track your team's performance on the leaderboard</li>
</ul>
<p style="text-align: center;">
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
</p>
<p>Visit your dashboard to see your team's upcoming events and current standings.</p>
{% else %}
<div class="status-message status-denied">
<p>Your request to join <strong>{{ team_name }}</strong> has not been approved at this time.</p>
<p>Don't worry - there are many teams in LeagueLedger that might be a better fit for you.</p>
</div>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="next-steps">
<h3>What's next?</h3>
<p>You can:</p>
<ul>
<li>Request to join another team</li>
<li>Create your own team</li>
<li>Explore upcoming pub quiz events in your area</li>
</ul>
</div>
{% endif %}
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
<div class="button-container">
<a href="{{ base_url }}/dashboard" class="button">Go to My Dashboard</a>
</div>
<p>Thank you for being part of the LeagueLedger community!</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© 2025 LeagueLedger. All rights reserved.</p>
<p>This email was sent to update you about your team join request.</p>
</div>
</div>
</body>
</html>
+65 -30
View File
@@ -12,64 +12,99 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
padding: 30px;
background-color: #ffffff;
}
.security-note {
padding: 15px;
margin: 20px 0;
background-color: #fff8e1;
border-left: 3px solid #ffd54f;
border-radius: 5px;
}
.button-container {
margin: 25px 0;
text-align: center;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
padding: 12px 25px;
border-radius: 5px;
margin: 20px 0;
font-weight: 500;
}
.link-help {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
font-size: 14px;
}
.footer {
margin-top: 20px;
font-size: 12px;
padding: 20px;
text-align: center;
font-size: 12px;
color: #777;
border-top: 1px solid #eaeaea;
}
</style>
</head>
<body>
<div class="header">
<h1>Password Reset</h1>
</div>
<div class="content">
<p>Hello {{ username }},</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>To reset your password, click the button below:</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{{ reset_url }}" class="button">Reset Password</a>
<div class="container">
<div class="header">
<h1>Password Reset</h1>
</div>
<p>Or you can copy and paste this link into your browser:</p>
<p>{{ reset_url }}</p>
<div class="content">
<p>Hello {{ username }},</p>
<p>This link will expire in 24 hours.</p>
<p>We received a request to reset your password for your LeagueLedger account. You can set a new password by clicking the button below:</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="button-container">
<a href="{{ reset_url }}" class="button">Reset My Password</a>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
<div class="security-note">
<p><strong>Security Note:</strong> This link will expire in 24 hours for your protection. If you did not request a password reset, please disregard this email and consider reviewing your account security.</p>
</div>
<div class="link-help">
<p>If the button above doesn't work, you can copy and paste this link into your browser:</p>
<p><a href="{{ reset_url }}">Reset your password</a></p>
</div>
<p>With LeagueLedger, you can continue to:</p>
<ul>
<li>Track your team's progress in pub quiz events</li>
<li>View your rank on the leaderboard</li>
<li>Manage your team memberships</li>
<li>Redeem QR codes for points</li>
</ul>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© 2025 LeagueLedger. All rights reserved.</p>
<p>This email was sent in response to your password reset request.</p>
</div>
</div>
</body>
</html>
+68 -36
View File
@@ -12,19 +12,27 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
padding: 30px;
background-color: #ffffff;
}
.button-container {
margin: 25px 0;
text-align: center;
}
.button {
display: inline-block;
@@ -33,54 +41,78 @@
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
font-weight: 500;
}
.button.approve {
background-color: #4CAF50;
background-color: #2D7738;
}
.button.deny {
background-color: #f44336;
background-color: #9e9e9e;
margin-left: 10px;
}
.footer {
.link-help {
margin-top: 20px;
font-size: 12px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
font-size: 14px;
}
.footer {
padding: 20px;
text-align: center;
font-size: 12px;
color: #777;
border-top: 1px solid #eaeaea;
}
.message-box {
margin: 15px 0;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
border-left: 3px solid #2D7738;
}
</style>
</head>
<body>
<div class="header">
<h1>Team Join Request</h1>
</div>
<div class="content">
<p>Hello {{ captain_name }},</p>
<p><strong>{{ requester_name }}</strong> has requested to join your team <strong>{{ team_name }}</strong>.</p>
{% if message %}
<p>Message from {{ requester_name }}:<br><em>"{{ message }}"</em></p>
{% endif %}
<p>You can approve or deny this request by clicking one of the buttons below:</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{{ approve_url }}" class="button approve">Approve Request</a>
<a href="{{ deny_url }}" class="button deny">Deny Request</a>
<div class="container">
<div class="header">
<h1>Team Join Request</h1>
</div>
<p>Or you can copy and paste one of these links into your browser:</p>
<p>Approve: {{ approve_url }}</p>
<p>Deny: {{ deny_url }}</p>
<div class="content">
<p>Hello {{ captain_name }},</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<p>We hope this email finds you well. <strong>{{ requester_name }}</strong> has requested to join your team <strong>{{ team_name }}</strong> on LeagueLedger.</p>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
{% if message %}
<div class="message-box">
<p><strong>Message from {{ requester_name }}:</strong></p>
<p><em>"{{ message }}"</em></p>
</div>
{% endif %}
<p>As the team captain, you can review this request and decide whether to approve or deny it.</p>
<div class="button-container">
<a href="{{ approve_url }}" class="button approve">Approve Request</a>
<a href="{{ deny_url }}" class="button deny">Decline Request</a>
</div>
<div class="link-help">
<p>If the buttons above don't work, you can copy and paste one of these links into your browser:</p>
<p>To approve: <a href="{{ approve_url }}">Accept team member</a></p>
<p>To decline: <a href="{{ deny_url }}">Decline request</a></p>
</div>
<p>Thank you for being an active team captain in our community!</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© 2025 LeagueLedger. All rights reserved.</p>
<p>This message was sent regarding team management in your LeagueLedger account.</p>
</div>
</div>
</body>
</html>
+65 -31
View File
@@ -12,67 +12,101 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.container {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background-color: #2D7738;
padding: 20px;
padding: 25px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 30px;
background-color: #ffffff;
}
.feature-list {
background-color: #f5f5f5;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
margin: 20px 0;
border-radius: 8px;
}
.button-container {
margin: 25px 0;
text-align: center;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
padding: 12px 25px;
border-radius: 5px;
margin: 20px 0;
font-weight: 500;
}
.getting-started {
margin-top: 20px;
padding: 15px;
background-color: #e8f5e9;
border-radius: 5px;
border-left: 4px solid #2D7738;
}
.footer {
margin-top: 20px;
font-size: 12px;
padding: 20px;
text-align: center;
font-size: 12px;
color: #777;
border-top: 1px solid #eaeaea;
}
</style>
</head>
<body>
<div class="header">
<h1>Welcome to LeagueLedger!</h1>
</div>
<div class="container">
<div class="header">
<h1>Welcome to LeagueLedger!</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<div class="content">
<p>Hello {{ username }},</p>
<p>Welcome to LeagueLedger! We're excited to have you join our community.</p>
<p>Thank you for joining LeagueLedger! We're delighted to have you as part of our community of pub quiz enthusiasts.</p>
<p>With LeagueLedger, you can:</p>
<ul>
<li>Track your team's progress</li>
<li>Participate in events</li>
<li>Collect points and earn achievements</li>
<li>Connect with other teams and players</li>
</ul>
<div class="feature-list">
<h3>With your new account, you can:</h3>
<ul>
<li><strong>Join or create teams</strong> - Connect with friends and form your pub quiz dream team</li>
<li><strong>Track your progress</strong> - Keep a record of all your quiz results in one place</li>
<li><strong>Earn achievements</strong> - Get recognition for your team's accomplishments</li>
<li><strong>Compete on leaderboards</strong> - See how your team ranks against others</li>
<li><strong>Scan QR codes</strong> - Easily record your points after quiz nights</li>
</ul>
</div>
<p>If you have any questions or need assistance, feel free to reach out to our support team.</p>
<div class="getting-started">
<h3>Getting Started</h3>
<p>The best way to begin is to either join an existing team or create your own. Visit your dashboard to get started!</p>
</div>
<p style="text-align: center;">
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
</p>
<div class="button-container">
<a href="{{ base_url }}/dashboard" class="button">Go to My Dashboard</a>
</div>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<p>If you have any questions or need assistance, you can reply to this email or visit our help center.</p>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
<p>We're excited to see you and your team climb the leaderboards!</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© 2025 LeagueLedger. All rights reserved.</p>
<p>This email was sent to welcome you to the LeagueLedger platform.</p>
</div>
</div>
</body>
</html>
+177 -33
View File
@@ -1,15 +1,21 @@
"""
Email utility module for LeagueLedger using FastAPI-Mail
Email utility module for LeagueLedger using Python's built-in email and smtplib packages
"""
import os
import smtplib
import asyncio
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from pathlib import Path
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Union
from fastapi import BackgroundTasks
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader
import logging
import re
from bs4 import BeautifulSoup
# Setup logging
logger = logging.getLogger(__name__)
@@ -26,51 +32,130 @@ MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME", "LeagueLedger")
MAIL_STARTTLS = os.getenv("MAIL_STARTTLS", "True").lower() == "true"
MAIL_SSL_TLS = os.getenv("MAIL_SSL_TLS", "False").lower() == "true"
USE_CREDENTIALS = os.getenv("MAIL_USE_CREDENTIALS", "True").lower() == "true"
VALIDATE_CERTS = os.getenv("MAIL_VALIDATE_CERTS", "True").lower() == "true"
APP_BASE_URL = os.getenv("LEAGUELEDGER_BASE_URL", os.getenv("APP_BASE_URL", "http://localhost:8000"))
# Configure Jinja2 for email templates
template_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
env = Environment(loader=FileSystemLoader(template_dir))
# Configure email connection
conf = ConnectionConfig(
MAIL_USERNAME=MAIL_USERNAME,
MAIL_PASSWORD=MAIL_PASSWORD,
MAIL_FROM=MAIL_FROM,
MAIL_PORT=MAIL_PORT,
MAIL_SERVER=MAIL_SERVER,
MAIL_FROM_NAME=MAIL_FROM_NAME,
MAIL_STARTTLS=MAIL_STARTTLS,
MAIL_SSL_TLS=MAIL_SSL_TLS,
USE_CREDENTIALS=USE_CREDENTIALS,
VALIDATE_CERTS=VALIDATE_CERTS
)
def html_to_plain_text(html_content):
"""
Convert HTML content to plain text for email alternatives.
This helps improve email deliverability by providing a plain text version.
"""
if not html_content:
return ""
async def send_email(
email_to: List[EmailStr],
# Use BeautifulSoup to parse HTML
try:
soup = BeautifulSoup(html_content, 'html.parser')
# Get text from HTML
text = soup.get_text(separator='\n', strip=True)
# Clean up extra whitespace and handle links
text = re.sub(r'\n\s+\n', '\n\n', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text
except Exception as e:
logger.error(f"Error converting HTML to plain text: {str(e)}")
# Fallback: Basic HTML tag removal
plain = re.sub('<.*?>', '', html_content)
return plain.replace('&nbsp;', ' ').strip()
async def _send_email_async(
email_to: Union[str, List[str]],
subject: str,
html_content: str,
background_tasks: BackgroundTasks
):
plain_text_content: Optional[str] = None
) -> bool:
"""
Asynchronous function to send emails using Python's built-in email and smtplib packages.
This function handles the actual sending of the email.
"""
# Convert single email to list if needed
recipients = [email_to] if isinstance(email_to, str) else email_to
# Generate plain text from HTML if not provided
if plain_text_content is None:
plain_text_content = html_to_plain_text(html_content)
try:
# Create multipart message container
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{MAIL_FROM_NAME} <{MAIL_FROM}>"
msg['To'] = ", ".join(recipients)
msg['Date'] = formatdate(localtime=True)
# Attach plain text part first (will be displayed if HTML not supported)
# RFC 2046 defines that the last part is preferred
part1 = MIMEText(plain_text_content, 'plain', 'utf-8')
msg.attach(part1)
# Attach HTML part last (will be preferred by most email clients)
part2 = MIMEText(html_content, 'html', 'utf-8')
msg.attach(part2)
# Run SMTP connection in a separate thread to avoid blocking
result = await asyncio.to_thread(_send_smtp_email, msg, recipients)
return result
except Exception as e:
logger.error(f"Failed to send email: {str(e)}")
return False
def _send_smtp_email(msg, recipients):
"""
Helper function to handle SMTP connection and sending.
This runs in a separate thread to avoid blocking the main event loop.
"""
try:
# Choose the appropriate SMTP connection method
if MAIL_SSL_TLS:
server = smtplib.SMTP_SSL(MAIL_SERVER, MAIL_PORT)
else:
server = smtplib.SMTP(MAIL_SERVER, MAIL_PORT)
# Use STARTTLS if configured
if MAIL_STARTTLS:
server.starttls()
# Login if credentials provided
if MAIL_USERNAME and MAIL_PASSWORD:
server.login(MAIL_USERNAME, MAIL_PASSWORD)
# Send email
server.send_message(msg)
server.quit()
logger.info(f"Email sent successfully to {recipients}")
return True
except Exception as e:
logger.error(f"SMTP error: {str(e)}")
return False
async def send_email(
email_to: Union[str, List[str]],
subject: str,
html_content: str,
background_tasks: BackgroundTasks,
plain_text_content: Optional[str] = None
) -> bool:
"""Generic function to send emails"""
try:
message = MessageSchema(
# Add email sending to background tasks
background_tasks.add_task(
_send_email_async,
email_to=email_to,
subject=subject,
recipients=[email_to] if isinstance(email_to, str) else email_to,
body=html_content,
subtype="html"
html_content=html_content,
plain_text_content=plain_text_content
)
fm = FastMail(conf)
# Send email in the background to avoid blocking the main thread
background_tasks.add_task(fm.send_message, message)
logger.info(f"Email queued for sending to {email_to}")
return True
except Exception as e:
logger.error(f"Failed to send email: {str(e)}")
logger.error(f"Failed to queue email: {str(e)}")
return False
async def send_password_reset_email(
@@ -178,12 +263,37 @@ async def send_verification_email(
verification_link=verification_link
)
# Send email
# Generate plain text version explicitly
plain_text = f"""
Hello {username},
Thank you for creating an account with LeagueLedger, your pub quiz team tracking platform. We're excited to have you join our community!
To ensure account security and complete your registration, please verify your email address by visiting this link:
{verification_link}
This verification step helps us keep your account secure and allows you to:
- Create or join teams
- Track your quiz night scores
- Earn points and achievements
- Climb the leaderboards
If you did not create an account with LeagueLedger, please disregard this email.
Best regards,
The LeagueLedger Team
© 2025 LeagueLedger. All rights reserved.
This email was sent to verify your account registration.
"""
# Send email with both HTML and plain text versions
subject = "Verify Your Email Address - LeagueLedger"
await send_email(
email_to=recipient_email,
subject=subject,
html_content=html_content,
plain_text_content=plain_text,
background_tasks=background_tasks
)
logger.info(f"Verification email sent to {recipient_email}")
@@ -226,3 +336,37 @@ async def send_join_request_response(
except Exception as e:
logger.error(f"Failed to send join request response email: {str(e)}")
return False
async def send_welcome_email(
email_to: str,
username: str,
background_tasks: BackgroundTasks
):
"""Send welcome email to newly registered users after verification"""
try:
# Get the email template
template = env.get_template("email/welcome.html")
# Render the HTML content with variables
html_content = template.render(
username=username,
base_url=APP_BASE_URL
)
# Generate plain text version
plain_text_content = html_to_plain_text(html_content)
# Send email
subject = "Welcome to LeagueLedger!"
await send_email(
email_to=email_to,
subject=subject,
html_content=html_content,
plain_text_content=plain_text_content,
background_tasks=background_tasks
)
logger.info(f"Welcome email sent to {email_to}")
return True
except Exception as e:
logger.error(f"Failed to send welcome email: {str(e)}")
return False
+6 -6
View File
@@ -220,6 +220,7 @@ async def register_post(
@router.get("/verify-email", response_class=HTMLResponse)
async def verify_email(
request: Request,
background_tasks: BackgroundTasks,
token: str,
db: Session = Depends(get_db)
):
@@ -243,11 +244,10 @@ async def verify_email(
# 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
await send_welcome_email(
email_to=user.email,
username=user.username,
background_tasks=background_tasks
)
except Exception as e:
print(f"Failed to queue welcome email: {str(e)}")
@@ -818,7 +818,7 @@ async def forgot_password_post(
try:
# Send reset email
await send_password_reset_email(
email=user.email,
email_to=user.email,
username=user.username,
reset_token=reset_token,
background_tasks=background_tasks,
+20 -33
View File
@@ -13,7 +13,7 @@ from starlette.status import HTTP_303_SEE_OTHER
from starlette.middleware.sessions import SessionMiddleware
from ..db import SessionLocal, get_db
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember, TeamJoinRequest
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamJoinRequest
from ..schemas import TeamCreate
from ..templates_config import templates
from ..utils.auth import get_current_user, is_team_captain
@@ -90,28 +90,21 @@ async def create_team_post(
name=name,
description=description,
logo_url=logo_url,
is_open=is_open # Save is_open value
is_open=is_open, # Save is_open value
owner_id=current_user.id # Set the owner_id field
)
db.add(team)
db.commit()
db.refresh(team)
# Make the user an admin of the team in TeamMembership
# Make the user an admin and captain of the team in TeamMembership
team_membership = TeamMembership(
user_id=current_user.id,
team_id=team.id,
is_admin=True # User becomes admin of the team
is_admin=True,
is_captain=True # User becomes both admin and captain of the team
)
db.add(team_membership)
# Create TeamMember relationship as well
team_member = TeamMember(
user_id=current_user.id,
team_id=team.id,
is_captain=True # Use is_captain instead of role
)
db.add(team_member)
db.commit()
return RedirectResponse("/teams/", status_code=HTTP_303_SEE_OTHER)
@@ -173,9 +166,9 @@ async def join_team_page(
)
# Check if user is already a member
existing_membership = db.query(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.user_id == current_user.id
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == current_user.id
).first()
if existing_membership:
@@ -232,9 +225,9 @@ async def join_team_request(
)
# Check if user is already a member
existing_membership = db.query(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.user_id == current_user.id
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == current_user.id
).first()
if existing_membership:
@@ -243,10 +236,11 @@ async def join_team_request(
# Open team - directly add the user
if team.is_open:
# Add user to team
new_member = TeamMember(
new_member = TeamMembership(
team_id=team_id,
user_id=current_user.id,
is_captain=False # Use is_captain instead of role
is_captain=False,
is_admin=False
)
db.add(new_member)
@@ -286,9 +280,9 @@ async def join_team_request(
db.commit()
# Get team captains to notify
captains = db.query(User).join(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.is_captain == True # Use is_captain instead of role
captains = db.query(User).join(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.is_captain == True
).all()
if not captains:
@@ -352,17 +346,10 @@ def direct_join_team(request: Request, team_id: int, db: Session = Depends(get_d
new_member = TeamMembership(
user_id=user_id,
team_id=team.id,
is_admin=False
is_admin=False,
is_captain=False
)
db.add(new_member)
# Also create TeamMember record for consistency
team_member = TeamMember(
user_id=user_id,
team_id=team.id,
is_captain=False # Use is_captain instead of role
)
db.add(team_member)
db.commit()
# Redirect to the team detail page
+1 -1
View File
@@ -110,7 +110,7 @@ services:
container_name: pubquiz_mailpit
restart: unless-stopped
ports:
- "8025:8025" # Web UI
- "8025:8025" # Web UI - Access emails at http://localhost:8025/
- "1025:1025" # SMTP Server
environment:
MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart)
+1 -1
View File
@@ -34,7 +34,7 @@ psutil>=5.9.0 # System monitoring and statistics
python-dateutil>=2.8.2 # Date manipulation utilities
# Email support
fastapi-mail>=1.4.2
beautifulsoup4>=4.12.2 # HTML parsing for emails
# Image processing library for QR code generation
Pillow>=9.0.0