- Add email_verified, verification_token, reset_token fields to User model - Create email_service.py with SendGrid integration - Add auth endpoints: verify-email, resend-verification, forgot-password, reset-password - Create frontend pages: /verify-email, /forgot-password, /reset-password - Add forgot password link to login page - Add PLATFORM_ENHANCEMENT_SPEC.md specification
98 lines
3.6 KiB
Python
98 lines
3.6 KiB
Python
"""
|
|
Email Service - SendGrid integration for verification and password reset emails.
|
|
"""
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
import httpx
|
|
|
|
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "")
|
|
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run")
|
|
|
|
async def send_email(to: str, subject: str, html_content: str) -> bool:
|
|
"""Send email via SendGrid API"""
|
|
if not SENDGRID_API_KEY:
|
|
print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
|
|
print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...")
|
|
return True
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
"https://api.sendgrid.com/v3/mail/send",
|
|
headers={
|
|
"Authorization": f"Bearer {SENDGRID_API_KEY}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
json={
|
|
"personalizations": [{"to": [{"email": to}]}],
|
|
"from": {"email": EMAIL_FROM, "name": "Elmeg"},
|
|
"subject": subject,
|
|
"content": [{"type": "text/html", "value": html_content}]
|
|
}
|
|
)
|
|
return response.status_code in (200, 202)
|
|
|
|
def generate_token() -> str:
|
|
"""Generate a secure random token"""
|
|
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 await 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 await send_email(email, "Reset your Elmeg password", html)
|
|
|
|
# Token expiration helpers
|
|
def get_verification_expiry() -> datetime:
|
|
"""48 hour expiry for email verification"""
|
|
return datetime.utcnow() + timedelta(hours=48)
|
|
|
|
def get_reset_expiry() -> datetime:
|
|
"""1 hour expiry for password reset"""
|
|
return datetime.utcnow() + timedelta(hours=1)
|