213 lines
7.1 KiB
Python
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()
|