Some checks failed
- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg - Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements - Fix legacy password_hash NOT NULL: alter column to nullable in migration - Add frontend password validation (uppercase + lowercase + digit) - Add forgot-password and reset-password backend endpoints - Add forgot-password and reset-password frontend pages - Add email_service.py (SMTP via admin settings) - Add reset_token/reset_token_expires columns to User model - Migrate legacy JSON-only users to DB on password reset request - Mount data/ volume in docker-compose.local.yml for persistence - Add production deployment config (Dockerfile, nginx, deploy.sh) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""
|
|
Email service for sending transactional emails via SMTP.
|
|
|
|
Supports both sync (smtplib) and async (aiosmtplib) sending.
|
|
Configuration is resolved from settings JSON file, then env vars.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_smtp_config() -> dict:
|
|
"""Resolve SMTP config from admin settings file then env vars."""
|
|
config = {}
|
|
|
|
# Read from admin provider settings (same file as /admin/settings page)
|
|
try:
|
|
import json
|
|
from pathlib import Path
|
|
settings_path = Path("data/provider_settings.json")
|
|
if settings_path.exists():
|
|
with open(settings_path, "r") as f:
|
|
settings = json.load(f)
|
|
smtp = settings.get("smtp", {})
|
|
if smtp and smtp.get("host"):
|
|
config["host"] = smtp.get("host", "")
|
|
config["port"] = smtp.get("port", 587)
|
|
config["user"] = smtp.get("username", "")
|
|
config["password"] = smtp.get("password", "")
|
|
config["from_email"] = smtp.get("from_email", "") or smtp.get("username", "")
|
|
config["use_tls"] = smtp.get("use_tls", True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Env vars fill gaps
|
|
config.setdefault("host", os.getenv("SMTP_HOST", ""))
|
|
config.setdefault("port", int(os.getenv("SMTP_PORT", "587")))
|
|
config.setdefault("user", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", "")))
|
|
config.setdefault("password", os.getenv("SMTP_PASSWORD", ""))
|
|
config.setdefault("from_email", os.getenv("SMTP_FROM_EMAIL", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", ""))))
|
|
config.setdefault("use_tls", os.getenv("SMTP_USE_TLS", "true").lower() == "true")
|
|
|
|
return config
|
|
|
|
|
|
def is_smtp_configured() -> bool:
|
|
"""Check if SMTP is properly configured."""
|
|
cfg = _get_smtp_config()
|
|
return bool(cfg.get("host") and cfg.get("user"))
|
|
|
|
|
|
def send_email(to: str, subject: str, body: str) -> bool:
|
|
"""Send an email via SMTP (sync). Returns True if sent successfully."""
|
|
cfg = _get_smtp_config()
|
|
if not cfg.get("host"):
|
|
logger.warning("SMTP not configured, email not sent")
|
|
return False
|
|
|
|
try:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = cfg["from_email"]
|
|
msg["To"] = to
|
|
msg.attach(MIMEText(body, "html"))
|
|
|
|
port = int(cfg.get("port", 587))
|
|
use_tls = cfg.get("use_tls", True)
|
|
|
|
if use_tls:
|
|
server = smtplib.SMTP(cfg["host"], port)
|
|
server.starttls()
|
|
else:
|
|
server = smtplib.SMTP(cfg["host"], port)
|
|
|
|
if cfg.get("user") and cfg.get("password"):
|
|
server.login(cfg["user"], cfg["password"])
|
|
|
|
server.sendmail(cfg["from_email"], [to], msg.as_string())
|
|
server.quit()
|
|
logger.info(f"Email sent to {to}: {subject}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email to {to}: {e}")
|
|
return False
|
|
|
|
|
|
async def send_email_async(to: str, subject: str, body: str) -> bool:
|
|
"""Send an email via SMTP (async). Returns True if sent successfully."""
|
|
cfg = _get_smtp_config()
|
|
if not cfg.get("host"):
|
|
logger.warning("SMTP not configured, email not sent")
|
|
return False
|
|
|
|
try:
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = cfg["from_email"]
|
|
msg["To"] = to
|
|
msg.attach(MIMEText(body, "html"))
|
|
|
|
port = int(cfg.get("port", 587))
|
|
|
|
# Use sync smtplib inside a thread for reliability (aiosmtplib has quirks with raw messages)
|
|
import asyncio
|
|
return await asyncio.get_event_loop().run_in_executor(
|
|
None, send_email, to, subject, body
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email (async) to {to}: {e}")
|
|
return False
|