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
+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 ""
# 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(
email_to: List[EmailStr],
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