Clawith/backend/app/services/system_email_service.py

324 lines
11 KiB
Python

"""System-owned outbound email service.
Supports both:
1. Platform-level configuration via environment variables
2. Tenant-level configuration via system_settings table
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import smtplib
import ssl
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, make_msgid
from app.core.email import force_ipv4, send_smtp_email
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class SystemEmailConfig:
"""Resolved system email configuration."""
from_address: str
from_name: str
smtp_host: str
smtp_port: int
smtp_username: str
smtp_password: str
smtp_ssl: bool
smtp_timeout_seconds: int
@dataclass(slots=True)
class BroadcastEmailRecipient:
"""Prepared broadcast recipient payload."""
email: str
subject: str
body: str
async def resolve_email_config_async(db) -> SystemEmailConfig | None:
"""Resolve email configuration by searching in order:
1. Platform-level settings in DB ('system_email_platform')
2. Environment variables (Settings class)
"""
from sqlalchemy import select
from app.models.system_settings import SystemSetting
# 1. Try platform-level config in DB
try:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == "system_email_platform"))
setting = result.scalar_one_or_none()
if setting and setting.value:
v = setting.value
if v.get("SYSTEM_EMAIL_FROM_ADDRESS") and v.get("SYSTEM_SMTP_HOST"):
return SystemEmailConfig(
from_address=str(v.get("SYSTEM_EMAIL_FROM_ADDRESS", "")).strip(),
from_name=str(v.get("SYSTEM_EMAIL_FROM_NAME", "Clawith")).strip() or "Clawith",
smtp_host=str(v.get("SYSTEM_SMTP_HOST", "")).strip(),
smtp_port=int(v.get("SYSTEM_SMTP_PORT", 465)),
smtp_username=str(v.get("SYSTEM_SMTP_USERNAME", "")).strip() or str(v.get("SYSTEM_EMAIL_FROM_ADDRESS", "")).strip(),
smtp_password=str(v.get("SYSTEM_SMTP_PASSWORD", "")),
smtp_ssl=bool(v.get("SYSTEM_SMTP_SSL", True)),
smtp_timeout_seconds=max(1, int(v.get("SYSTEM_SMTP_TIMEOUT_SECONDS", 15))),
)
except Exception as e:
logger.warning(f"Error resolving platform email config: {e}")
return None
async def send_system_email(to: str, subject: str, body: str, db=None) -> None:
"""Send a plain-text system email without blocking the event loop.
Args:
to: Recipient email address
subject: Email subject
body: Email body text
db: Optional database session
"""
if not db:
from app.database import async_session
async with async_session() as session:
config = await resolve_email_config_async(session)
else:
config = await resolve_email_config_async(db)
if not config:
logger.warning(f"System email not configured, skipped sending to {to}")
return
await asyncio.to_thread(_send_email_with_config_sync, config, to, subject, body)
def _send_email_with_config_sync(config: SystemEmailConfig, to: str, subject: str, body: str) -> None:
"""Send email with provided config."""
msg = MIMEMultipart()
msg["From"] = formataddr((config.from_name, config.from_address))
msg["To"] = to
msg["Subject"] = subject
msg["Message-ID"] = make_msgid()
msg["Date"] = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
msg.attach(MIMEText(body, "plain", "utf-8"))
send_smtp_email(
host=config.smtp_host,
port=config.smtp_port,
user=config.smtp_username,
password=config.smtp_password,
from_addr=config.from_address,
to_addrs=[to],
msg_string=msg.as_string(),
use_ssl=config.smtp_ssl,
timeout=config.smtp_timeout_seconds,
)
async def send_password_reset_email(
to: str,
display_name: str,
reset_url: str,
expiry_minutes: int,
db=None,
) -> None:
"""Send a password reset email using the configured template.
Args:
to: Recipient email
display_name: User display name
reset_url: Password reset URL
expiry_minutes: Token expiry time in minutes
db: Optional database session
"""
variables = {
"display_name": display_name,
"reset_url": reset_url,
"expiry_minutes": str(expiry_minutes),
}
subject, body = await render_email_template("password_reset", variables, db=db)
await send_system_email(to, subject, body, db=db)
async def send_company_invitation_email(
to: str,
inviter_name: str,
company_name: str,
invite_url: str,
db=None,
) -> None:
"""Send a company invitation email using the configured template.
Args:
to: Recipient email
inviter_name: Name of the person inviting
company_name: Name of the company
invite_url: Registration URL with invitation code
db: Optional database session
"""
variables = {
"inviter_name": inviter_name,
"company_name": company_name,
"invite_url": invite_url,
}
subject, body = await render_email_template("company_invitation", variables, db=db)
await send_system_email(to, subject, body, db=db)
async def deliver_broadcast_emails(recipients: Iterable[BroadcastEmailRecipient]) -> None:
"""Deliver broadcast emails while isolating per-recipient failures."""
for recipient in recipients:
try:
await send_system_email(recipient.email, recipient.subject, recipient.body)
except Exception as exc:
logger.warning("Failed to deliver broadcast email to %s: %s", recipient.email, exc)
# ── Email Templates ──────────────────────────────────────────────────────────
# Default templates for each email scenario.
# Each scenario has a fixed set of available variables (using {{variable}} syntax).
DEFAULT_EMAIL_TEMPLATES: dict[str, dict[str, str]] = {
"email_verification": {
"subject": "Verify your Clawith email address",
"body": (
"Hello {{display_name}},\n\n"
"Welcome to Clawith! Please use the following 6-digit code to verify your email address:\n\n"
"Verification code: {{verification_code}}\n\n"
"This code expires in {{expiry_minutes}} minutes. "
"If you did not create an account, you can ignore this email."
),
},
"password_reset": {
"subject": "Reset your Clawith password",
"body": (
"Hello {{display_name}},\n\n"
"We received a request to reset your Clawith password.\n\n"
"Reset link: {{reset_url}}\n\n"
"This link expires in {{expiry_minutes}} minutes. "
"If you did not request this, you can ignore this email."
),
},
"company_invitation": {
"subject": "{{inviter_name}} invited you to join {{company_name}} on Clawith",
"body": (
"Hello,\n\n"
"{{inviter_name}} has invited you to join their team '{{company_name}}' on Clawith.\n\n"
"To accept the invitation and create your account, please click the link below:\n\n"
"{{invite_url}}\n\n"
"If you don't want to join this team or didn't expect this invitation, you can ignore this email."
),
},
}
# Fixed available variables per scenario (for frontend display)
EMAIL_TEMPLATE_VARIABLES: dict[str, list[str]] = {
"email_verification": ["display_name", "verification_code", "expiry_minutes"],
"password_reset": ["display_name", "reset_url", "expiry_minutes"],
"company_invitation": ["inviter_name", "company_name", "invite_url"],
}
async def get_email_templates(db=None) -> dict[str, dict[str, str]]:
"""Load email templates from DB, falling back to defaults.
Returns:
A dict mapping scenario_key -> {"subject": str, "body": str}
"""
from sqlalchemy import select
from app.models.system_settings import SystemSetting
templates = dict(DEFAULT_EMAIL_TEMPLATES) # start with defaults
if not db:
from app.database import async_session
async with async_session() as session:
return await _load_templates_from_db(session, templates)
return await _load_templates_from_db(db, templates)
async def _load_templates_from_db(db, templates: dict) -> dict:
"""Internal helper: overlay DB-saved templates on top of defaults."""
from sqlalchemy import select
from app.models.system_settings import SystemSetting
try:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "email_templates")
)
setting = result.scalar_one_or_none()
if setting and setting.value:
saved = setting.value
for key in templates:
if key in saved and isinstance(saved[key], dict):
# Only override subject/body if present and non-empty
if saved[key].get("subject"):
templates[key]["subject"] = saved[key]["subject"]
if saved[key].get("body"):
templates[key]["body"] = saved[key]["body"]
except Exception as e:
logger.warning(f"Error loading email templates from DB: {e}")
return templates
def _render_template(template_str: str, variables: dict[str, str]) -> str:
"""Replace {{variable_name}} placeholders with actual values."""
result = template_str
for key, value in variables.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
async def render_email_template(
scenario_key: str,
variables: dict[str, str],
db=None,
) -> tuple[str, str]:
"""Render an email template for a given scenario.
Args:
scenario_key: One of the known scenario keys (e.g. 'email_verification')
variables: Dict of variable_name -> value to substitute
db: Optional database session
Returns:
(subject, body) tuple with variables substituted
"""
templates = await get_email_templates(db=db)
template = templates.get(scenario_key, DEFAULT_EMAIL_TEMPLATES.get(scenario_key, {}))
subject = _render_template(template.get("subject", ""), variables)
body = _render_template(template.get("body", ""), variables)
return subject, body
async def send_test_email(to: str, db=None) -> None:
"""Send a test email to verify SMTP configuration.
Args:
to: Recipient email address
db: Optional database session for resolving config
"""
subject = "Clawith Test Email"
body = (
"This is a test email from your Clawith platform.\n\n"
"If you received this email, your SMTP configuration is working correctly.\n\n"
"-- Clawith System"
)
await send_system_email(to, subject, body, db=db)