initial commit after split
This commit is contained in:
420
src/govoplan_mail/backend/router.py
Normal file
420
src/govoplan_mail/backend/router.py
Normal file
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from govoplan_mail.backend.schemas import (
|
||||
MailConnectionTestResponse,
|
||||
MailImapFolderListResponse,
|
||||
MailImapFolderResponse,
|
||||
MailImapTestRequest,
|
||||
MailProfilePolicyResponse,
|
||||
MailProfilePolicyUpdateRequest,
|
||||
MailServerProfileCreateRequest,
|
||||
MailServerProfileListResponse,
|
||||
MailServerProfileResponse,
|
||||
MailServerProfileUpdateRequest,
|
||||
MailSmtpTestRequest,
|
||||
)
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
MailProfileError,
|
||||
create_mail_server_profile,
|
||||
effective_mail_profile_policy,
|
||||
get_mail_profile_policy,
|
||||
get_mail_server_profile,
|
||||
imap_config_from_profile,
|
||||
list_mail_server_profiles,
|
||||
profile_response_payload,
|
||||
set_mail_profile_policy,
|
||||
smtp_config_from_profile,
|
||||
update_mail_server_profile,
|
||||
)
|
||||
from govoplan_mail.backend.sending.imap import list_imap_folders, test_imap_login
|
||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||
|
||||
|
||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(has_scope(principal, scope) for scope in scopes):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
|
||||
|
||||
|
||||
def _require_profile_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
else:
|
||||
_require_scope(principal, "mail:profile:write")
|
||||
|
||||
|
||||
def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
else:
|
||||
_require_scope(principal, "mail:secret:manage")
|
||||
|
||||
|
||||
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:read")
|
||||
elif scope_type == "tenant":
|
||||
_require_any_scope(principal, "admin:policies:read", "mail:profile:read")
|
||||
else:
|
||||
_require_any_scope(principal, "admin:policies:read", "mail:profile:read", "campaigns:campaign:read")
|
||||
|
||||
|
||||
def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
elif scope_type == "tenant":
|
||||
_require_scope(principal, "admin:policies:write")
|
||||
else:
|
||||
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
|
||||
|
||||
|
||||
def _profile_response(profile) -> MailServerProfileResponse:
|
||||
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
||||
def list_profiles(
|
||||
include_inactive: bool = False,
|
||||
campaign_id: str | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||
try:
|
||||
profiles = list_mail_server_profiles(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
include_inactive=include_inactive,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=MailServerProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_profile(
|
||||
payload: MailServerProfileCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_profile_write_scope(principal, payload.scope_type)
|
||||
if payload.smtp.password or (payload.imap and payload.imap.password):
|
||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||
try:
|
||||
profile = create_mail_server_profile(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
description=payload.description,
|
||||
smtp=payload.smtp,
|
||||
imap=payload.imap,
|
||||
is_active=payload.is_active,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return _profile_response(profile)
|
||||
except MailProfileError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||
def get_profile(
|
||||
profile_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||
try:
|
||||
return _profile_response(get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id))
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||
def update_profile(
|
||||
profile_id: str,
|
||||
payload: MailServerProfileUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
|
||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||
update_mail_server_profile(
|
||||
session,
|
||||
profile,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
description=payload.description if "description" in payload.model_fields_set else None,
|
||||
is_active=payload.is_active,
|
||||
smtp=payload.smtp,
|
||||
imap=payload.imap,
|
||||
clear_imap=payload.clear_imap,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return _profile_response(profile)
|
||||
except MailProfileError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||
def deactivate_profile(
|
||||
profile_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||
profile.is_active = False
|
||||
session.add(profile)
|
||||
session.commit()
|
||||
session.refresh(profile)
|
||||
return _profile_response(profile)
|
||||
except MailProfileError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
||||
def read_mail_profile_policy(
|
||||
scope_type: str,
|
||||
scope_id: str | None = Query(default=None),
|
||||
campaign_id: str | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
scope_type = scope_type.strip().casefold()
|
||||
_require_policy_read_scope(principal, scope_type)
|
||||
try:
|
||||
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||
effective = None
|
||||
if campaign_id:
|
||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id).as_dict()
|
||||
elif scope_type == "campaign" and scope_id:
|
||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
||||
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
||||
def write_mail_profile_policy(
|
||||
scope_type: str,
|
||||
payload: MailProfilePolicyUpdateRequest,
|
||||
scope_id: str | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
scope_type = scope_type.strip().casefold()
|
||||
_require_policy_write_scope(principal, scope_type)
|
||||
try:
|
||||
policy = set_mail_profile_policy(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
policy=payload.policy.model_dump(mode="json"),
|
||||
)
|
||||
session.commit()
|
||||
effective = None
|
||||
if scope_type == "campaign" and scope_id:
|
||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
||||
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
||||
except MailProfileError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
|
||||
def test_profile_smtp(
|
||||
profile_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:test")
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||
smtp = smtp_config_from_profile(profile)
|
||||
result = test_smtp_login(smtp_config=smtp)
|
||||
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
return MailConnectionTestResponse(ok=False, protocol="smtp", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/test-imap", response_model=MailConnectionTestResponse)
|
||||
def test_profile_imap(
|
||||
profile_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:test")
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is None:
|
||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||
result = test_imap_login(imap_config=imap)
|
||||
return MailConnectionTestResponse(ok=True, protocol="imap", host=result.host, port=result.port, security=result.security, message="IMAP connection successful.", details={"authenticated": result.authenticated})
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse)
|
||||
def list_profile_imap_folders(
|
||||
profile_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:test")
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is None:
|
||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||
result = list_imap_folders(imap_config=imap)
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
|
||||
|
||||
|
||||
def _safe_error_message(exc: Exception) -> str:
|
||||
text = str(exc).strip()
|
||||
return text or exc.__class__.__name__
|
||||
|
||||
|
||||
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
|
||||
def test_smtp_settings(
|
||||
payload: MailSmtpTestRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
"""Test SMTP connectivity/login without sending any message."""
|
||||
|
||||
if not has_scope(principal, "mail:profile:test"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||
if not has_scope(principal, "mail:secret:manage"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||
try:
|
||||
result = test_smtp_login(smtp_config=payload)
|
||||
return MailConnectionTestResponse(
|
||||
ok=True,
|
||||
protocol="smtp",
|
||||
host=result.host,
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
message="SMTP connection successful.",
|
||||
details={"authenticated": result.authenticated},
|
||||
)
|
||||
except Exception as exc:
|
||||
return MailConnectionTestResponse(
|
||||
ok=False,
|
||||
protocol="smtp",
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
security=payload.security.value,
|
||||
message=_safe_error_message(exc),
|
||||
details={"error_type": exc.__class__.__name__},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-imap", response_model=MailConnectionTestResponse)
|
||||
def test_imap_settings(
|
||||
payload: MailImapTestRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
"""Test IMAP connectivity/login without selecting or appending messages."""
|
||||
|
||||
if not has_scope(principal, "mail:profile:test"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||
if not has_scope(principal, "mail:secret:manage"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||
try:
|
||||
result = test_imap_login(imap_config=payload)
|
||||
return MailConnectionTestResponse(
|
||||
ok=True,
|
||||
protocol="imap",
|
||||
host=result.host,
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
message="IMAP connection successful.",
|
||||
details={"authenticated": result.authenticated},
|
||||
)
|
||||
except Exception as exc:
|
||||
return MailConnectionTestResponse(
|
||||
ok=False,
|
||||
protocol="imap",
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
security=payload.security.value,
|
||||
message=_safe_error_message(exc),
|
||||
details={"error_type": exc.__class__.__name__},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
|
||||
def list_imap_folder_settings(
|
||||
payload: MailImapTestRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
"""List visible IMAP folders and return the best Sent-folder guess."""
|
||||
|
||||
if not has_scope(principal, "mail:profile:test"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
||||
if not has_scope(principal, "mail:secret:manage"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||
try:
|
||||
result = list_imap_folders(imap_config=payload)
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||
return MailImapFolderListResponse(
|
||||
ok=True,
|
||||
host=result.host,
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
message=f"Found {len(folders)} IMAP folder(s).",
|
||||
folders=folders,
|
||||
detected_sent_folder=result.detected_sent_folder,
|
||||
)
|
||||
except Exception as exc:
|
||||
return MailImapFolderListResponse(
|
||||
ok=False,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
security=payload.security.value,
|
||||
message=_safe_error_message(exc),
|
||||
folders=[],
|
||||
detected_sent_folder=None,
|
||||
details={"error_type": exc.__class__.__name__},
|
||||
)
|
||||
Reference in New Issue
Block a user