feat: Enhance email templates and add plain text support for improved deliverability
This commit is contained in:
+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 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(' ', ' ').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
|
||||
|
||||
Reference in New Issue
Block a user