Files
saas_backend/app/routes/system/tenant_email.py
T
2026-08-31 20:39:41 -04:00

213 lines
7.1 KiB
Python

"""A workspace configuring its own outgoing mail.
One configuration per workspace, scoped by the caller's session. Managing it is
session-only, like API keys and webhooks: a credential that can change where the
platform sends invitations from is a credential that can redirect them.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.helper.helpers import get_client_ip
from app.middleware.auth_middleware import User, get_current_user, require_access
from app.models.system.tenant_email_model import TenantEmailSettings
from app.services.system import tenant_email_service
from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
class EmailSettingsPayload(BaseModel):
smtp_host: str = Field(min_length=1, max_length=255)
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_user: Optional[str] = Field(default=None, max_length=255)
smtp_password: Optional[str] = None
use_ssl: bool = False
from_address: EmailStr
from_name: Optional[str] = Field(default=None, max_length=150)
class EmailSettingsResponse(BaseModel):
"""No password field, deliberately.
`password_set` says whether one exists, which is all a configuration screen
needs — and unlike the value itself, it is safe in a log.
"""
model_config = ConfigDict(from_attributes=True)
smtp_host: str
smtp_port: int
smtp_user: Optional[str] = None
use_ssl: bool
from_address: str
from_name: Optional[str] = None
is_active: bool
last_verified_at: Optional[datetime] = None
last_error: Optional[str] = None
password_set: bool = False
class TestRequest(BaseModel):
to_email: EmailStr
def _session_only(user: User) -> None:
if getattr(user, "_saas_api_key_id", None) is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API keys cannot change email settings. Sign in to do this.",
)
def _workspace(user: User):
if user.tenant_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Account is not associated with a workspace")
return user.tenant_id
def _existing(db: Session, tenant_id) -> Optional[TenantEmailSettings]:
return (
db.query(TenantEmailSettings)
.filter(TenantEmailSettings.tenant_id == tenant_id)
.first()
)
def _to_response(settings: TenantEmailSettings) -> EmailSettingsResponse:
payload = EmailSettingsResponse.model_validate(settings)
payload.password_set = bool(settings.smtp_password_enc)
return payload
@router.get("", response_model=Optional[EmailSettingsResponse])
def read_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.email.manage")),
):
"""`null` when nothing is configured — which is the ordinary case, and means
the platform's own account sends everything."""
settings = _existing(db, _workspace(current_user))
return _to_response(settings) if settings else None
@router.put("", response_model=EmailSettingsResponse)
def save_settings(
payload: EmailSettingsPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.email.manage")),
):
"""Save, but do not switch on.
Saving deactivates whatever was there: the settings have changed and have
not been proved, and leaving the old flag set would mean a typo silently
breaks every invitation until somebody notices. A test send is what turns it
back on.
"""
_session_only(current_user)
tenant_id = _workspace(current_user)
problem = tenant_email_service.check_destination(
payload.smtp_host, payload.smtp_port
)
if problem:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=problem)
settings = _existing(db, tenant_id)
if settings is None:
settings = TenantEmailSettings(tenant_id=tenant_id,
smtp_host=payload.smtp_host,
from_address=str(payload.from_address))
db.add(settings)
settings.smtp_host = payload.smtp_host
settings.smtp_port = payload.smtp_port
settings.smtp_user = payload.smtp_user
settings.use_ssl = payload.use_ssl
settings.from_address = str(payload.from_address)
settings.from_name = payload.from_name
if payload.smtp_password:
settings.smtp_password = payload.smtp_password
settings.is_active = False
settings.last_error = None
db.flush()
AuditLogService.log(
db=db, module_name="Settings", action_type="UPDATE",
entity_id=str(settings.id), entity_name=settings.from_address,
description="Outgoing email settings changed (" + settings.smtp_host + ")",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=tenant_id,
)
db.commit()
db.refresh(settings)
return _to_response(settings)
@router.post("/test", response_model=EmailSettingsResponse)
def send_test(
payload: TestRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.email.manage")),
):
"""Send a test message. Success is what activates the configuration.
The failure text comes back verbatim: "authentication failed" and
"connection refused" call for different fixes, and the customer is the only
one who can make either.
"""
_session_only(current_user)
settings = _existing(db, _workspace(current_user))
if settings is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Nothing is configured yet")
tenant_email_service.verify(db, settings, str(payload.to_email))
db.commit()
db.refresh(settings)
return _to_response(settings)
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
def clear_settings(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.email.manage")),
):
"""Go back to the platform's own account. Not a failure state — a workspace
that no longer runs its own relay should be able to say so."""
_session_only(current_user)
settings = _existing(db, _workspace(current_user))
if settings is None:
return
host = settings.smtp_host
db.delete(settings)
AuditLogService.log(
db=db, module_name="Settings", action_type="DELETE",
entity_id=str(current_user.tenant_id), entity_name=host,
description="Outgoing email settings removed; sending reverts to the platform",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()