forked from andika/membership-be
378 lines
17 KiB
Python
378 lines
17 KiB
Python
import os
|
|
import ssl
|
|
import smtplib
|
|
import asyncio
|
|
from pathlib import Path
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
import logging
|
|
from dotenv import load_dotenv
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Load .env file
|
|
ROOT_DIR = Path(__file__).parent
|
|
load_dotenv(ROOT_DIR / '.env')
|
|
|
|
# SMTP Configuration - supports both new (SMTP_USER/SMTP_PASS) and old (SMTP_USERNAME/SMTP_PASSWORD) variable names
|
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
|
|
SMTP_SECURE = os.environ.get('SMTP_SECURE', 'false').lower() == 'true'
|
|
SMTP_VERIFY_CERT = os.environ.get('SMTP_VERIFY_CERT', 'true').lower() == 'true'
|
|
SMTP_AUTH_METHOD = os.environ.get('SMTP_AUTH_METHOD', None) # PLAIN, LOGIN, or CRAM-MD5
|
|
|
|
# Log configuration at startup
|
|
logger.info(f"📧 SMTP Configuration Loaded:")
|
|
logger.info(f" Host: {SMTP_HOST}:{SMTP_PORT}")
|
|
logger.info(f" Secure (SSL from start): {SMTP_SECURE}")
|
|
logger.info(f" Verify Certificate: {SMTP_VERIFY_CERT}")
|
|
logger.info(f" Auth Method: {SMTP_AUTH_METHOD or 'auto-detect'}")
|
|
|
|
# Support both SMTP_USER and SMTP_USERNAME (new and old)
|
|
SMTP_USER = os.environ.get('SMTP_USER') or os.environ.get('SMTP_USERNAME', '')
|
|
SMTP_PASS = os.environ.get('SMTP_PASS') or os.environ.get('SMTP_PASSWORD', '')
|
|
|
|
# Support both SMTP_FROM and SMTP_FROM_EMAIL (new and old)
|
|
SMTP_FROM = os.environ.get('SMTP_FROM') or os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com')
|
|
SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'LOAF Membership')
|
|
|
|
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
|
|
|
|
def _send_email_sync(message: MIMEMultipart, to_email: str):
|
|
"""
|
|
Synchronous email sending (will be wrapped in asyncio.to_thread)
|
|
Matches the working pattern from previous implementation
|
|
"""
|
|
# Create SSL context for certificate verification control
|
|
ssl_context = None
|
|
if not SMTP_VERIFY_CERT:
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
logger.warning(f"⚠️ SSL certificate verification DISABLED for {SMTP_HOST}")
|
|
|
|
# Determine connection method based on configuration
|
|
if not SMTP_SECURE:
|
|
# Plain SMTP without encryption (some providers require this)
|
|
logger.info(f"🔌 Using plain SMTP connection (SMTP_SECURE=false) to {SMTP_HOST}:{SMTP_PORT}")
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
logger.info(f" ✅ SMTP connection established")
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
logger.info(f" ✅ Login successful")
|
|
server.send_message(message)
|
|
logger.info(f" ✅ Message sent")
|
|
elif SMTP_PORT == 465:
|
|
# Use SSL/TLS from start (standard for port 465)
|
|
logger.info(f"🔌 Using SMTP_SSL connection to {SMTP_HOST}:{SMTP_PORT}")
|
|
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ssl_context) as server:
|
|
logger.info(f" ✅ SSL connection established")
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
logger.info(f" ✅ Login successful")
|
|
server.send_message(message)
|
|
else:
|
|
# Use STARTTLS (standard for port 587)
|
|
logger.info(f"🔌 Using SMTP with STARTTLS to {SMTP_HOST}:{SMTP_PORT}")
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
server.starttls(context=ssl_context)
|
|
logger.info(f" ✅ STARTTLS enabled")
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
logger.info(f" ✅ Login successful")
|
|
server.send_message(message)
|
|
|
|
|
|
async def send_email(to_email: str, subject: str, html_content: str):
|
|
"""
|
|
Send an email using SMTP
|
|
|
|
Configuration via environment variables:
|
|
- SMTP_SECURE=false: Plain SMTP without encryption
|
|
- SMTP_SECURE=true + Port 465: SSL/TLS from start
|
|
- SMTP_SECURE=true + Port 587: STARTTLS
|
|
"""
|
|
try:
|
|
message = MIMEMultipart('alternative')
|
|
message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>"
|
|
message['To'] = to_email
|
|
message['Subject'] = subject
|
|
|
|
html_part = MIMEText(html_content, 'html')
|
|
message.attach(html_part)
|
|
|
|
# For development/testing, just log the email
|
|
if not SMTP_USER or not SMTP_PASS:
|
|
logger.info(f"[EMAIL] To: {to_email}")
|
|
logger.info(f"[EMAIL] Subject: {subject}")
|
|
logger.info(f"[EMAIL] Content: {html_content[:200]}...")
|
|
return True
|
|
|
|
logger.info(f"📧 Sending email to {to_email} via {SMTP_HOST}:{SMTP_PORT}")
|
|
|
|
# Run synchronous SMTP in thread pool to avoid blocking
|
|
await asyncio.to_thread(_send_email_sync, message, to_email)
|
|
|
|
logger.info(f"✓ Email sent successfully to {to_email}")
|
|
return True
|
|
|
|
except smtplib.SMTPAuthenticationError as e:
|
|
logger.error(f"❌ SMTP Authentication Error for {to_email}: {str(e)}")
|
|
logger.error(f" Check SMTP_USER and SMTP_PASS")
|
|
return False
|
|
except smtplib.SMTPException as e:
|
|
logger.error(f"❌ SMTP Error sending to {to_email}: {str(e)}")
|
|
logger.exception(e)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"✗ Failed to send email to {to_email}: {str(e)}")
|
|
logger.exception(e)
|
|
return False
|
|
|
|
async def send_verification_email(to_email: str, token: str):
|
|
"""Send email verification link"""
|
|
verification_url = f"{FRONTEND_URL}/verify-email?token={token}"
|
|
subject = "Verify Your Email Address"
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
|
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
|
|
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
|
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
|
|
.button:hover {{ background: #D0694E; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Welcome to Our Community!</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Thank you for registering with us. We're excited to have you join our community.</p>
|
|
<p>Please click the button below to verify your email address:</p>
|
|
<p style="text-align: center;">
|
|
<a href="{verification_url}" class="button">Verify Email</a>
|
|
</p>
|
|
<p>Or copy and paste this link into your browser:</p>
|
|
<p style="word-break: break-all; color: #6B708D;">{verification_url}</p>
|
|
<p>This link will expire in 24 hours.</p>
|
|
<p>If you didn't create an account, please ignore this email.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return await send_email(to_email, subject, html_content)
|
|
|
|
async def send_approval_notification(to_email: str, first_name: str):
|
|
"""Send notification when user is approved"""
|
|
login_url = f"{FRONTEND_URL}/login"
|
|
subject = "Your Membership Application Has Been Approved!"
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
|
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
|
|
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
|
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Congratulations, {first_name}!</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Great news! Your membership application has been approved.</p>
|
|
<p>You now have full access to all member features and events.</p>
|
|
<p style="text-align: center;">
|
|
<a href="{login_url}" class="button">Login to Your Account</a>
|
|
</p>
|
|
<p>We look forward to seeing you at our events!</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return await send_email(to_email, subject, html_content)
|
|
|
|
async def send_payment_prompt_email(to_email: str, first_name: str):
|
|
"""Send payment prompt email after admin approval"""
|
|
payment_url = f"{FRONTEND_URL}/plans"
|
|
subject = "Complete Your LOAF Membership - Payment Required"
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
|
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
|
|
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
|
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
|
|
.button:hover {{ background: #D0694E; }}
|
|
.benefits {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }}
|
|
.benefits ul {{ list-style: none; padding: 0; }}
|
|
.benefits li {{ padding: 8px 0; padding-left: 25px; position: relative; }}
|
|
.benefits li:before {{ content: "✓"; position: absolute; left: 0; color: #E07A5F; font-weight: bold; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🎉 You're Approved, {first_name}!</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p><strong>Great news!</strong> Your LOAF membership application has been approved by our admin team.</p>
|
|
|
|
<p>To activate your membership and gain full access, please complete your annual membership payment:</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{payment_url}" class="button">Complete Payment →</a>
|
|
</p>
|
|
|
|
<div class="benefits">
|
|
<p><strong>Once payment is processed, you'll have immediate access to:</strong></p>
|
|
<ul>
|
|
<li>Members-only events and gatherings</li>
|
|
<li>Community directory and networking</li>
|
|
<li>Exclusive member benefits and discounts</li>
|
|
<li>LOAF newsletter and updates</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p>We're excited to have you join the LOAF community!</p>
|
|
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #E8E4DB; color: #6B708D; font-size: 14px;">
|
|
Questions? Contact us at support@loaf.org
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return await send_email(to_email, subject, html_content)
|
|
|
|
async def send_password_reset_email(to_email: str, first_name: str, reset_url: str):
|
|
"""Send password reset link email"""
|
|
subject = "Reset Your Password - LOAF Membership"
|
|
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
|
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
|
|
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
|
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
|
|
.button:hover {{ background: #D0694E; }}
|
|
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Reset Your Password</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Hi {first_name},</p>
|
|
<p>You requested to reset your password. Click the button below to create a new password:</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{reset_url}" class="button">Reset Password</a>
|
|
</p>
|
|
|
|
<div class="note">
|
|
<p style="margin: 0; font-size: 14px;"><strong>⏰ This link will expire in 1 hour.</strong></p>
|
|
<p style="margin: 5px 0 0 0; font-size: 14px;">If you didn't request this, please ignore this email.</p>
|
|
</div>
|
|
|
|
<p style="margin-top: 20px; color: #6B708D; font-size: 14px;">
|
|
Or copy and paste this link into your browser:<br>
|
|
<span style="word-break: break-all;">{reset_url}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return await send_email(to_email, subject, html_content)
|
|
|
|
async def send_admin_password_reset_email(
|
|
to_email: str,
|
|
first_name: str,
|
|
temp_password: str,
|
|
force_change: bool
|
|
):
|
|
"""Send temporary password when admin resets user password"""
|
|
subject = "Your Password Has Been Reset - LOAF Membership"
|
|
|
|
force_change_text = (
|
|
"""
|
|
<div class="note" style="background: #FFEBEE; border-left: 4px solid #E07A5F;">
|
|
<p style="margin: 0; font-weight: bold; color: #E07A5F;">⚠️ You will be required to change this password when you log in.</p>
|
|
</div>
|
|
"""
|
|
) if force_change else ""
|
|
|
|
login_url = f"{FRONTEND_URL}/login"
|
|
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
|
|
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
|
|
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
|
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
|
|
.button:hover {{ background: #D0694E; }}
|
|
.password-box {{ background: #F5F5F5; padding: 20px; margin: 20px 0; border-left: 4px solid #E07A5F; font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; word-break: break-all; }}
|
|
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Password Reset by Administrator</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Hi {first_name},</p>
|
|
<p>An administrator has reset your password. Here is your temporary password:</p>
|
|
|
|
<div class="password-box">
|
|
{temp_password}
|
|
</div>
|
|
|
|
{force_change_text}
|
|
|
|
<p>Please log in and change your password to something memorable.</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{login_url}" class="button">Go to Login</a>
|
|
</p>
|
|
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #E8E4DB; color: #6B708D; font-size: 14px;">
|
|
Questions? Contact us at support@loaf.org
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return await send_email(to_email, subject, html_content)
|