refactor: Use SES v2 stored templates in Python email service

- Switch from raw HTML to stored templates
- Use sesv2 client instead of ses
- Add send_security_alert_email function
- Templates: ELMEG_EMAIL_VERIFICATION, ELMEG_PASSWORD_RESET, ELMEG_SECURITY_ALERT
This commit is contained in:
fullsizemalt 2025-12-21 16:55:15 -08:00
parent 530f217445
commit 415a092257

View file

@ -1,115 +1,146 @@
""" """
Email Service - AWS SES integration for verification and password reset emails. Email Service - AWS SES v2 integration using stored templates.
Uses SES stored templates for consistent, branded transactional emails:
- ELMEG_EMAIL_VERIFICATION
- ELMEG_PASSWORD_RESET
- ELMEG_SECURITY_ALERT
""" """
import os import os
import json
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
import boto3 import boto3
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
# Configuration
AWS_REGION = os.getenv("AWS_SES_REGION", "us-east-1") AWS_REGION = os.getenv("AWS_SES_REGION", "us-east-1")
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz") EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.xyz") FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
SUPPORT_EMAIL = os.getenv("SUPPORT_EMAIL", "support@elmeg.xyz")
APP_NAME = "Elmeg"
# SES Template Names
TEMPLATE_VERIFICATION = "ELMEG_EMAIL_VERIFICATION"
TEMPLATE_PASSWORD_RESET = "ELMEG_PASSWORD_RESET"
TEMPLATE_SECURITY_ALERT = "ELMEG_SECURITY_ALERT"
# AWS credentials from environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
# or from IAM role if running on AWS infrastructure
def get_ses_client(): def get_ses_client():
"""Get boto3 SES client""" """Get boto3 SES v2 client"""
return boto3.client('ses', region_name=AWS_REGION) return boto3.client('sesv2', region_name=AWS_REGION)
def send_email(to: str, subject: str, html_content: str) -> bool: def is_email_configured() -> bool:
"""Send email via AWS SES""" """Check if email is properly configured"""
# Dev mode - just log return bool(os.getenv("AWS_ACCESS_KEY_ID") and os.getenv("AWS_SECRET_ACCESS_KEY"))
if not os.getenv("AWS_ACCESS_KEY_ID"):
print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...") def send_templated_email(
return True to: str,
template_name: str,
template_data: dict
) -> dict:
"""
Send email using SES stored template.
Returns:
dict with 'success', 'message_id' (on success), 'error' (on failure)
"""
# Dev mode - log instead of sending
if not is_email_configured():
print(f"[EMAIL DEV MODE] To: {to}, Template: {template_name}")
print(f"[EMAIL DEV MODE] Data: {json.dumps(template_data, indent=2)}")
return {"success": True, "message_id": "dev-mode", "dev_mode": True}
try: try:
client = get_ses_client() client = get_ses_client()
response = client.send_email( response = client.send_email(
Source=EMAIL_FROM, FromEmailAddress=EMAIL_FROM,
Destination={'ToAddresses': [to]}, Destination={"ToAddresses": [to]},
Message={ Content={
'Subject': {'Data': subject, 'Charset': 'UTF-8'}, "Template": {
'Body': { "TemplateName": template_name,
'Html': {'Data': html_content, 'Charset': 'UTF-8'} "TemplateData": json.dumps(template_data)
} }
} }
) )
print(f"Email sent to {to}, MessageId: {response['MessageId']}") message_id = response.get("MessageId", "unknown")
return True print(f"[Email] Sent {template_name} to {to}, MessageId: {message_id}")
except ClientError as e: return {"success": True, "message_id": message_id}
print(f"Failed to send email: {e.response['Error']['Message']}")
return False
except ClientError as e:
error_msg = e.response.get('Error', {}).get('Message', str(e))
print(f"[Email] Failed to send {template_name} to {to}: {error_msg}")
return {"success": False, "error": error_msg}
# =============================================================================
# Email Functions
# =============================================================================
async def send_verification_email(email: str, token: str, user_name: Optional[str] = None) -> bool:
"""Send email verification using SES template"""
verification_link = f"{FRONTEND_URL}/verify-email?token={token}"
template_data = {
"user_name": user_name or email.split("@")[0],
"verification_link": verification_link,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_VERIFICATION, template_data)
return result["success"]
async def send_password_reset_email(email: str, token: str, user_name: Optional[str] = None) -> bool:
"""Send password reset email using SES template"""
reset_link = f"{FRONTEND_URL}/reset-password?token={token}"
template_data = {
"user_name": user_name or email.split("@")[0],
"reset_link": reset_link,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_PASSWORD_RESET, template_data)
return result["success"]
async def send_security_alert_email(
email: str,
security_event_description: str,
user_name: Optional[str] = None
) -> bool:
"""Send security alert email using SES template"""
template_data = {
"user_name": user_name or email.split("@")[0],
"security_event_description": security_event_description,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_SECURITY_ALERT, template_data)
return result["success"]
# =============================================================================
# Token Generation & Expiry Helpers
# =============================================================================
def generate_token() -> str: def generate_token() -> str:
"""Generate a secure random token""" """Generate a secure random token"""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
async def send_verification_email(email: str, token: str) -> bool:
"""Send email verification link"""
verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
html = f"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Welcome to Elmeg!</h1>
<p>Please verify your email address by clicking the button below:</p>
<a href="{verify_url}"
style="display: inline-block; background: #4F46E5; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 16px 0;">
Verify Email
</a>
<p style="color: #666; font-size: 14px;">
Or copy this link: {verify_url}
</p>
<p style="color: #999; font-size: 12px;">
This link expires in 48 hours.
</p>
</div>
"""
return send_email(email, "Verify your Elmeg account", html)
async def send_password_reset_email(email: str, token: str) -> bool:
"""Send password reset link"""
reset_url = f"{FRONTEND_URL}/reset-password?token={token}"
html = f"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Password Reset</h1>
<p>You requested a password reset. Click below to set a new password:</p>
<a href="{reset_url}"
style="display: inline-block; background: #4F46E5; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 16px 0;">
Reset Password
</a>
<p style="color: #666; font-size: 14px;">
Or copy this link: {reset_url}
</p>
<p style="color: #999; font-size: 12px;">
This link expires in 1 hour. If you didn't request this, ignore this email.
</p>
</div>
"""
return send_email(email, "Reset your Elmeg password", html)
# Token expiration helpers
def get_verification_expiry() -> datetime: def get_verification_expiry() -> datetime:
"""48 hour expiry for email verification""" """24 hour expiry for email verification"""
return datetime.utcnow() + timedelta(hours=48) return datetime.utcnow() + timedelta(hours=24)
def get_reset_expiry() -> datetime: def get_reset_expiry() -> datetime:
"""1 hour expiry for password reset""" """1 hour expiry for password reset"""
return datetime.utcnow() + timedelta(hours=1) return datetime.utcnow() + timedelta(hours=1)