feat: Enhance email templates and add plain text support for improved deliverability
This commit is contained in:
@@ -55,10 +55,10 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl
|
|||||||
## User Management & Profile Features
|
## User Management & Profile Features
|
||||||
|
|
||||||
- [ ] **Profile Management**
|
- [ ] **Profile Management**
|
||||||
- [ ] Update profile picture functionality
|
- [x] Update profile picture functionality
|
||||||
- [ ] Change username capability
|
- [x] Change username capability
|
||||||
- [ ] Account deletion process
|
- [x] Account deletion process
|
||||||
- [ ] Profile privacy settings
|
- [x] Profile privacy settings
|
||||||
- [ ] Social media integration
|
- [ ] Social media integration
|
||||||
|
|
||||||
## Environment Variables & Configuration
|
## Environment Variables & Configuration
|
||||||
@@ -158,4 +158,10 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl
|
|||||||
- [ ] Create database migration tools
|
- [ ] Create database migration tools
|
||||||
- [ ] Add support for clustering/high availability
|
- [ ] Add support for clustering/high availability
|
||||||
- [ ] Implement CDN for static assets
|
- [ ] Implement CDN for static assets
|
||||||
- [ ] Create backup and disaster recovery procedures
|
- [ ] 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
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
|
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
|
||||||
from .db import SessionLocal, engine
|
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)]
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
return column_name in columns
|
return column_name in columns
|
||||||
|
|
||||||
def init_db():
|
async def init_db():
|
||||||
"""Initialize the database, applying migrations and seeding data."""
|
"""Initialize the database, applying migrations and seeding data."""
|
||||||
# First, create all tables if they don't exist
|
# First, create all tables if they don't exist
|
||||||
from .models import Base
|
from .models import Base
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
# Then, run any needed migrations
|
# Then, run any needed migrations
|
||||||
run_migrations(engine)
|
await asyncio.to_thread(run_migrations, engine)
|
||||||
# Then proceed with seeding if needed
|
# Then proceed with seeding if needed
|
||||||
seed_db()
|
await asyncio.to_thread(seed_db)
|
||||||
# Initialize system settings if needed
|
# Initialize system settings if needed
|
||||||
init_system_settings()
|
await asyncio.to_thread(init_system_settings)
|
||||||
|
|
||||||
def init_system_settings():
|
def init_system_settings():
|
||||||
"""Initialize the system settings table if it doesn't exist."""
|
"""Initialize the system settings table if it doesn't exist."""
|
||||||
@@ -173,23 +174,24 @@ def seed_db():
|
|||||||
memberships = []
|
memberships = []
|
||||||
membership_data = [
|
membership_data = [
|
||||||
# Quiz Wizards
|
# Quiz Wizards
|
||||||
(1, 1, True, 160), # Admin user is team admin of Quiz Wizards
|
(1, 1, True, True, 160), # Admin user is team admin AND captain of Quiz Wizards
|
||||||
(2, 1, True, 155),
|
(2, 1, True, True, 155), # John is also admin AND captain
|
||||||
(3, 1, False, 130),
|
(3, 1, False, False, 130),
|
||||||
(4, 1, False, 90),
|
(4, 1, False, False, 90),
|
||||||
(5, 1, False, 45),
|
(5, 1, False, False, 45),
|
||||||
# Trivia Titans
|
# Trivia Titans
|
||||||
(2, 2, True, 150),
|
(2, 2, True, True, 150), # John is admin AND captain of Trivia Titans
|
||||||
(1, 2, False, 145),
|
(1, 2, False, False, 145),
|
||||||
# Beer Brainiacs
|
# 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 = {
|
membership_attrs = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"team_id": team_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:
|
if has_joined_at:
|
||||||
membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago)
|
membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago)
|
||||||
@@ -366,4 +368,4 @@ def seed_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
asyncio.run(init_db())
|
||||||
|
|||||||
+74
-6
@@ -11,6 +11,11 @@ from starlette.middleware.authentication import AuthenticationMiddleware
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import logging
|
import logging
|
||||||
import contextlib
|
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 .db import engine, get_db
|
||||||
from . import models
|
from . import models
|
||||||
@@ -52,10 +57,54 @@ app.add_middleware(
|
|||||||
# Then add SessionMiddleware (last added = first executed)
|
# Then add SessionMiddleware (last added = first executed)
|
||||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||||
|
|
||||||
# Initialize database on startup
|
# Function to check database connection
|
||||||
@app.on_event("startup")
|
async def check_db_connection(max_retries=10, initial_retry_delay=1):
|
||||||
async def startup_db_client():
|
"""
|
||||||
logger.info("Starting database initialization")
|
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:
|
try:
|
||||||
# Import models to ensure they're registered with Base before initialization
|
# Import models to ensure they're registered with Base before initialization
|
||||||
from . import models
|
from . import models
|
||||||
@@ -63,10 +112,29 @@ async def startup_db_client():
|
|||||||
# Initialize database (applies migrations and seeds data)
|
# Initialize database (applies migrations and seeds data)
|
||||||
init_db()
|
init_db()
|
||||||
logger.info("Database initialized and migrated successfully")
|
logger.info("Database initialized and migrated successfully")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database initialization error: {str(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
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|||||||
@@ -12,62 +12,92 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
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 {
|
.header {
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 30px;
|
||||||
border: 1px solid #ddd;
|
background-color: #ffffff;
|
||||||
border-top: none;
|
}
|
||||||
border-radius: 0 0 5px 5px;
|
.button-container {
|
||||||
|
margin: 25px 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 10px 20px;
|
padding: 12px 25px;
|
||||||
border-radius: 5px;
|
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 {
|
.footer {
|
||||||
margin-top: 20px;
|
padding: 20px;
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #777;
|
color: #777;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<h1>Verify Your Email Address</h1>
|
<div class="header">
|
||||||
</div>
|
<h1>Email Verification</h1>
|
||||||
|
</div>
|
||||||
<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>
|
<div class="content">
|
||||||
|
<p>Hello {{ username }},</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>To ensure account security and complete your registration, please verify your email address by clicking the button below:</p>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="{{ verification_link }}" class="button">Verify My Email</a>
|
||||||
|
</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="{{ verification_link }}">Complete your verification</a></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>
|
||||||
|
|
||||||
|
<p>If you did not create an account with LeagueLedger, please disregard this email.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>The LeagueLedger Team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style="text-align: center;">
|
<div class="footer">
|
||||||
<a href="{{ verification_link }}" class="button">Verify Email</a>
|
<p>© 2025 LeagueLedger. All rights reserved.</p>
|
||||||
</p>
|
<p>This email was sent to verify your account registration.</p>
|
||||||
|
</div>
|
||||||
<p>Or copy and paste this link into your browser:</p>
|
|
||||||
<p>{{ verification_link }}</p>
|
|
||||||
|
|
||||||
<p>If you did not create an account with us, please ignore this email.</p>
|
|
||||||
|
|
||||||
<p>Best regards,<br>The LeagueLedger Team</p>
|
|
||||||
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -12,63 +12,119 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
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 {
|
.header {
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 30px;
|
||||||
border: 1px solid #ddd;
|
background-color: #ffffff;
|
||||||
border-top: none;
|
}
|
||||||
border-radius: 0 0 5px 5px;
|
.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 {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
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;
|
border-radius: 5px;
|
||||||
margin: 20px 0;
|
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 20px;
|
padding: 20px;
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #777;
|
color: #777;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<h1>Team Join Request {{ "Approved" if is_approved else "Denied" }}</h1>
|
<div class="header">
|
||||||
</div>
|
<h1>Team Request {{ "Approved" if is_approved else "Status Update" }}</h1>
|
||||||
|
</div>
|
||||||
<div class="content">
|
|
||||||
<p>Hello {{ username }},</p>
|
|
||||||
|
|
||||||
{% if is_approved %}
|
<div class="content">
|
||||||
<p>Good news! Your request to join <strong>{{ team_name }}</strong> has been approved. You are now a member of the team.</p>
|
<p>Hello {{ username }},</p>
|
||||||
{% else %}
|
|
||||||
<p>We regret to inform you that your request to join <strong>{{ team_name }}</strong> has been denied.</p>
|
{% if is_approved %}
|
||||||
{% endif %}
|
<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>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>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>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
|
||||||
<p>You can view your teams by visiting your dashboard:</p>
|
<div class="footer">
|
||||||
|
<p>© 2025 LeagueLedger. All rights reserved.</p>
|
||||||
<p style="text-align: center;">
|
<p>This email was sent to update you about your team join request.</p>
|
||||||
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Best regards,<br>The LeagueLedger Team</p>
|
|
||||||
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,64 +12,99 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
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 {
|
.header {
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 30px;
|
||||||
border: 1px solid #ddd;
|
background-color: #ffffff;
|
||||||
border-top: none;
|
}
|
||||||
border-radius: 0 0 5px 5px;
|
.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 {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 10px 20px;
|
padding: 12px 25px;
|
||||||
border-radius: 5px;
|
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 {
|
.footer {
|
||||||
margin-top: 20px;
|
padding: 20px;
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #777;
|
color: #777;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<h1>Password Reset</h1>
|
<div class="header">
|
||||||
</div>
|
<h1>Password Reset</h1>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<p>Or you can copy and paste this link into your browser:</p>
|
<div class="content">
|
||||||
<p>{{ reset_url }}</p>
|
<p>Hello {{ username }},</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>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="{{ reset_url }}" class="button">Reset My Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<p>This link will expire in 24 hours.</p>
|
<div class="footer">
|
||||||
|
<p>© 2025 LeagueLedger. All rights reserved.</p>
|
||||||
<p>Best regards,<br>The LeagueLedger Team</p>
|
<p>This email was sent in response to your password reset request.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,19 +12,27 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
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 {
|
.header {
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 30px;
|
||||||
border: 1px solid #ddd;
|
background-color: #ffffff;
|
||||||
border-top: none;
|
}
|
||||||
border-radius: 0 0 5px 5px;
|
.button-container {
|
||||||
|
margin: 25px 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -33,54 +41,78 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin: 20px 0;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.button.approve {
|
.button.approve {
|
||||||
background-color: #4CAF50;
|
background-color: #2D7738;
|
||||||
}
|
}
|
||||||
.button.deny {
|
.button.deny {
|
||||||
background-color: #f44336;
|
background-color: #9e9e9e;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
.footer {
|
.link-help {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
font-size: 12px;
|
padding: 15px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #777;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<h1>Team Join Request</h1>
|
<div class="header">
|
||||||
</div>
|
<h1>Team Join Request</h1>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<p>Or you can copy and paste one of these links into your browser:</p>
|
<div class="content">
|
||||||
<p>Approve: {{ approve_url }}</p>
|
<p>Hello {{ captain_name }},</p>
|
||||||
<p>Deny: {{ deny_url }}</p>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
<p>Best regards,<br>The LeagueLedger Team</p>
|
<div class="footer">
|
||||||
</div>
|
<p>© 2025 LeagueLedger. All rights reserved.</p>
|
||||||
|
<p>This message was sent regarding team management in your LeagueLedger account.</p>
|
||||||
<div class="footer">
|
</div>
|
||||||
<p>© LeagueLedger. All rights reserved.</p>
|
|
||||||
<p>This is an automated message, please do not reply to this email.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,67 +12,101 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
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 {
|
.header {
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
padding: 20px;
|
padding: 25px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
.feature-list {
|
||||||
|
background-color: #f5f5f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid #ddd;
|
margin: 20px 0;
|
||||||
border-top: none;
|
border-radius: 8px;
|
||||||
border-radius: 0 0 5px 5px;
|
}
|
||||||
|
.button-container {
|
||||||
|
margin: 25px 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #2D7738;
|
background-color: #2D7738;
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 10px 20px;
|
padding: 12px 25px;
|
||||||
border-radius: 5px;
|
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 {
|
.footer {
|
||||||
margin-top: 20px;
|
padding: 20px;
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
color: #777;
|
color: #777;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<h1>Welcome to LeagueLedger!</h1>
|
<div class="header">
|
||||||
</div>
|
<h1>Welcome to LeagueLedger!</h1>
|
||||||
|
</div>
|
||||||
<div class="content">
|
|
||||||
<p>Hello {{ username }},</p>
|
|
||||||
|
|
||||||
<p>Welcome to LeagueLedger! We're excited to have you join our community.</p>
|
<div class="content">
|
||||||
|
<p>Hello {{ username }},</p>
|
||||||
|
|
||||||
|
<p>Thank you for joining LeagueLedger! We're delighted to have you as part of our community of pub quiz enthusiasts.</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<a href="{{ base_url }}/dashboard" class="button">Go to My Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you have any questions or need assistance, you can reply to this email or visit our help center.</p>
|
||||||
|
|
||||||
|
<p>We're excited to see you and your team climb the leaderboards!</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>The LeagueLedger Team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>With LeagueLedger, you can:</p>
|
<div class="footer">
|
||||||
<ul>
|
<p>© 2025 LeagueLedger. All rights reserved.</p>
|
||||||
<li>Track your team's progress</li>
|
<p>This email was sent to welcome you to the LeagueLedger platform.</p>
|
||||||
<li>Participate in events</li>
|
</div>
|
||||||
<li>Collect points and earn achievements</li>
|
|
||||||
<li>Connect with other teams and players</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>If you have any questions or need assistance, feel free to reach out to our support team.</p>
|
|
||||||
|
|
||||||
<p style="text-align: center;">
|
|
||||||
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Best regards,<br>The LeagueLedger Team</p>
|
|
||||||
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+177
-33
@@ -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 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 pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional, Union
|
||||||
from fastapi import BackgroundTasks
|
from fastapi import BackgroundTasks
|
||||||
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logger = logging.getLogger(__name__)
|
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_FROM_NAME = os.getenv("MAIL_FROM_NAME", "LeagueLedger")
|
||||||
MAIL_STARTTLS = os.getenv("MAIL_STARTTLS", "True").lower() == "true"
|
MAIL_STARTTLS = os.getenv("MAIL_STARTTLS", "True").lower() == "true"
|
||||||
MAIL_SSL_TLS = os.getenv("MAIL_SSL_TLS", "False").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"))
|
APP_BASE_URL = os.getenv("LEAGUELEDGER_BASE_URL", os.getenv("APP_BASE_URL", "http://localhost:8000"))
|
||||||
|
|
||||||
# Configure Jinja2 for email templates
|
# Configure Jinja2 for email templates
|
||||||
template_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
template_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
||||||
env = Environment(loader=FileSystemLoader(template_dir))
|
env = Environment(loader=FileSystemLoader(template_dir))
|
||||||
|
|
||||||
# Configure email connection
|
def html_to_plain_text(html_content):
|
||||||
conf = ConnectionConfig(
|
"""
|
||||||
MAIL_USERNAME=MAIL_USERNAME,
|
Convert HTML content to plain text for email alternatives.
|
||||||
MAIL_PASSWORD=MAIL_PASSWORD,
|
This helps improve email deliverability by providing a plain text version.
|
||||||
MAIL_FROM=MAIL_FROM,
|
"""
|
||||||
MAIL_PORT=MAIL_PORT,
|
if not html_content:
|
||||||
MAIL_SERVER=MAIL_SERVER,
|
return ""
|
||||||
MAIL_FROM_NAME=MAIL_FROM_NAME,
|
|
||||||
MAIL_STARTTLS=MAIL_STARTTLS,
|
# Use BeautifulSoup to parse HTML
|
||||||
MAIL_SSL_TLS=MAIL_SSL_TLS,
|
try:
|
||||||
USE_CREDENTIALS=USE_CREDENTIALS,
|
soup = BeautifulSoup(html_content, 'html.parser')
|
||||||
VALIDATE_CERTS=VALIDATE_CERTS
|
|
||||||
)
|
# 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(' ', ' ').strip()
|
||||||
|
|
||||||
async def send_email(
|
async def _send_email_async(
|
||||||
email_to: List[EmailStr],
|
email_to: Union[str, List[str]],
|
||||||
subject: str,
|
subject: str,
|
||||||
html_content: 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"""
|
"""Generic function to send emails"""
|
||||||
try:
|
try:
|
||||||
message = MessageSchema(
|
# Add email sending to background tasks
|
||||||
|
background_tasks.add_task(
|
||||||
|
_send_email_async,
|
||||||
|
email_to=email_to,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
recipients=[email_to] if isinstance(email_to, str) else email_to,
|
html_content=html_content,
|
||||||
body=html_content,
|
plain_text_content=plain_text_content
|
||||||
subtype="html"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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}")
|
logger.info(f"Email queued for sending to {email_to}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send email: {str(e)}")
|
logger.error(f"Failed to queue email: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def send_password_reset_email(
|
async def send_password_reset_email(
|
||||||
@@ -178,12 +263,37 @@ async def send_verification_email(
|
|||||||
verification_link=verification_link
|
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"
|
subject = "Verify Your Email Address - LeagueLedger"
|
||||||
await send_email(
|
await send_email(
|
||||||
email_to=recipient_email,
|
email_to=recipient_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html_content=html_content,
|
html_content=html_content,
|
||||||
|
plain_text_content=plain_text,
|
||||||
background_tasks=background_tasks
|
background_tasks=background_tasks
|
||||||
)
|
)
|
||||||
logger.info(f"Verification email sent to {recipient_email}")
|
logger.info(f"Verification email sent to {recipient_email}")
|
||||||
@@ -226,3 +336,37 @@ async def send_join_request_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send join request response email: {str(e)}")
|
logger.error(f"Failed to send join request response email: {str(e)}")
|
||||||
return False
|
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
@@ -220,6 +220,7 @@ async def register_post(
|
|||||||
@router.get("/verify-email", response_class=HTMLResponse)
|
@router.get("/verify-email", response_class=HTMLResponse)
|
||||||
async def verify_email(
|
async def verify_email(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
token: str,
|
token: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -243,11 +244,10 @@ async def verify_email(
|
|||||||
# Send welcome email in the background
|
# Send welcome email in the background
|
||||||
try:
|
try:
|
||||||
from ..utils.mail import send_welcome_email
|
from ..utils.mail import send_welcome_email
|
||||||
background_tasks = BackgroundTasks()
|
await send_welcome_email(
|
||||||
background_tasks.add_task(
|
email_to=user.email,
|
||||||
send_welcome_email,
|
username=user.username,
|
||||||
email=user.email,
|
background_tasks=background_tasks
|
||||||
username=user.username
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to queue welcome email: {str(e)}")
|
print(f"Failed to queue welcome email: {str(e)}")
|
||||||
@@ -818,7 +818,7 @@ async def forgot_password_post(
|
|||||||
try:
|
try:
|
||||||
# Send reset email
|
# Send reset email
|
||||||
await send_password_reset_email(
|
await send_password_reset_email(
|
||||||
email=user.email,
|
email_to=user.email,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
reset_token=reset_token,
|
reset_token=reset_token,
|
||||||
background_tasks=background_tasks,
|
background_tasks=background_tasks,
|
||||||
|
|||||||
+20
-33
@@ -13,7 +13,7 @@ from starlette.status import HTTP_303_SEE_OTHER
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from ..db import SessionLocal, get_db
|
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 ..schemas import TeamCreate
|
||||||
from ..templates_config import templates
|
from ..templates_config import templates
|
||||||
from ..utils.auth import get_current_user, is_team_captain
|
from ..utils.auth import get_current_user, is_team_captain
|
||||||
@@ -90,28 +90,21 @@ async def create_team_post(
|
|||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
logo_url=logo_url,
|
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.add(team)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(team)
|
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(
|
team_membership = TeamMembership(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
team_id=team.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)
|
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()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse("/teams/", status_code=HTTP_303_SEE_OTHER)
|
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
|
# Check if user is already a member
|
||||||
existing_membership = db.query(TeamMember).filter(
|
existing_membership = db.query(TeamMembership).filter(
|
||||||
TeamMember.team_id == team_id,
|
TeamMembership.team_id == team_id,
|
||||||
TeamMember.user_id == current_user.id
|
TeamMembership.user_id == current_user.id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_membership:
|
if existing_membership:
|
||||||
@@ -232,9 +225,9 @@ async def join_team_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if user is already a member
|
# Check if user is already a member
|
||||||
existing_membership = db.query(TeamMember).filter(
|
existing_membership = db.query(TeamMembership).filter(
|
||||||
TeamMember.team_id == team_id,
|
TeamMembership.team_id == team_id,
|
||||||
TeamMember.user_id == current_user.id
|
TeamMembership.user_id == current_user.id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_membership:
|
if existing_membership:
|
||||||
@@ -243,10 +236,11 @@ async def join_team_request(
|
|||||||
# Open team - directly add the user
|
# Open team - directly add the user
|
||||||
if team.is_open:
|
if team.is_open:
|
||||||
# Add user to team
|
# Add user to team
|
||||||
new_member = TeamMember(
|
new_member = TeamMembership(
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
user_id=current_user.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)
|
db.add(new_member)
|
||||||
@@ -286,9 +280,9 @@ async def join_team_request(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Get team captains to notify
|
# Get team captains to notify
|
||||||
captains = db.query(User).join(TeamMember).filter(
|
captains = db.query(User).join(TeamMembership).filter(
|
||||||
TeamMember.team_id == team_id,
|
TeamMembership.team_id == team_id,
|
||||||
TeamMember.is_captain == True # Use is_captain instead of role
|
TeamMembership.is_captain == True
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not captains:
|
if not captains:
|
||||||
@@ -352,17 +346,10 @@ def direct_join_team(request: Request, team_id: int, db: Session = Depends(get_d
|
|||||||
new_member = TeamMembership(
|
new_member = TeamMembership(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
team_id=team.id,
|
team_id=team.id,
|
||||||
is_admin=False
|
is_admin=False,
|
||||||
|
is_captain=False
|
||||||
)
|
)
|
||||||
db.add(new_member)
|
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()
|
db.commit()
|
||||||
|
|
||||||
# Redirect to the team detail page
|
# Redirect to the team detail page
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ services:
|
|||||||
container_name: pubquiz_mailpit
|
container_name: pubquiz_mailpit
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025" # Web UI
|
- "8025:8025" # Web UI - Access emails at http://localhost:8025/
|
||||||
- "1025:1025" # SMTP Server
|
- "1025:1025" # SMTP Server
|
||||||
environment:
|
environment:
|
||||||
MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart)
|
MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart)
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ psutil>=5.9.0 # System monitoring and statistics
|
|||||||
python-dateutil>=2.8.2 # Date manipulation utilities
|
python-dateutil>=2.8.2 # Date manipulation utilities
|
||||||
|
|
||||||
# Email support
|
# Email support
|
||||||
fastapi-mail>=1.4.2
|
beautifulsoup4>=4.12.2 # HTML parsing for emails
|
||||||
|
|
||||||
# Image processing library for QR code generation
|
# Image processing library for QR code generation
|
||||||
Pillow>=9.0.0
|
Pillow>=9.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user