Files
gh-christianlouis-dmarq/backend/app/api/api_v1/endpoints/mail_sources.py
T

311 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Mail Sources API endpoints.
Provides CRUD operations for MailSource objects stored in the database, plus
a *test-connection* action that validates the supplied credentials without
persisting anything.
"""
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.mail_source import MailSource
from app.services.imap_client import IMAPClient
router = APIRouter()
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class MailSourceBase(BaseModel):
"""Fields shared by create and update payloads."""
name: str
method: str = "IMAP" # IMAP | POP3 | GMAIL_API
server: Optional[str] = None
port: int = 993
username: Optional[str] = None
password: Optional[str] = None
use_ssl: bool = True
folder: str = "INBOX"
polling_interval: int = 60
enabled: bool = True
class MailSourceCreate(MailSourceBase):
"""Payload for creating a new mail source."""
class MailSourceUpdate(BaseModel):
"""Payload for partial updates all fields optional."""
name: Optional[str] = None
method: Optional[str] = None
server: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
use_ssl: Optional[bool] = None
folder: Optional[str] = None
polling_interval: Optional[int] = None
enabled: Optional[bool] = None
class MailSourceResponse(MailSourceBase):
"""Response schema exposes the stored row without exposing raw password."""
id: int
last_checked: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Mask the stored password in responses
password: Optional[str] = None
class Config:
from_attributes = True
class TestConnectionRequest(BaseModel):
"""Credentials for an ad-hoc connection test (not persisted)."""
server: Optional[str] = None
port: int = 993
username: Optional[str] = None
password: Optional[str] = None
ssl: bool = True
method: str = "IMAP"
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _sanitize_for_log(value: object) -> str:
"""Remove CR/LF characters from a value to prevent log injection attacks."""
return str(value).replace("\r", "").replace("\n", " ")
def _get_source_or_404(source_id: int, db: Session) -> MailSource:
source = db.query(MailSource).filter(MailSource.id == source_id).first()
if source is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mail source {source_id} not found",
)
return source
def _source_to_response(source: MailSource) -> MailSourceResponse:
"""Convert ORM row to response schema, masking the stored password."""
return MailSourceResponse(
id=source.id,
name=source.name,
method=source.method,
server=source.server,
port=source.port or 993,
username=source.username,
password="**redacted**" if source.password else None,
use_ssl=source.use_ssl if source.use_ssl is not None else True,
folder=source.folder or "INBOX",
polling_interval=source.polling_interval or 60,
enabled=source.enabled if source.enabled is not None else True,
last_checked=source.last_checked,
created_at=source.created_at,
updated_at=source.updated_at,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("", response_model=List[MailSourceResponse])
async def list_mail_sources(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> List[MailSourceResponse]:
"""Return all configured mail sources (passwords redacted)."""
sources = db.query(MailSource).order_by(MailSource.id).all()
return [_source_to_response(s) for s in sources]
@router.post("", response_model=MailSourceResponse, status_code=status.HTTP_201_CREATED)
async def create_mail_source(
payload: MailSourceCreate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Create a new mail source."""
source = MailSource(
name=payload.name,
method=payload.method.upper(),
server=payload.server,
port=payload.port,
username=payload.username,
password=payload.password,
use_ssl=payload.use_ssl,
folder=payload.folder,
polling_interval=payload.polling_interval,
enabled=payload.enabled,
)
db.add(source)
db.commit()
db.refresh(source)
logger.info(
"Created mail source id=%d name=%r method=%r", source.id, source.name, source.method
)
return _source_to_response(source)
@router.get("/{source_id}", response_model=MailSourceResponse)
async def get_mail_source(
source_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Return a single mail source by ID (password redacted)."""
source = _get_source_or_404(source_id, db)
return _source_to_response(source)
@router.put("/{source_id}", response_model=MailSourceResponse)
async def update_mail_source(
source_id: int,
payload: MailSourceUpdate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Update one or more fields of an existing mail source."""
source = _get_source_or_404(source_id, db)
update_data = payload.model_dump(exclude_unset=True)
if "method" in update_data and update_data["method"]:
update_data["method"] = update_data["method"].upper()
for field, value in update_data.items():
setattr(source, field, value)
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
logger.info("Updated mail source id=%d", source.id)
return _source_to_response(source)
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_mail_source(
source_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> None:
"""Delete a mail source permanently."""
source = _get_source_or_404(source_id, db)
db.delete(source)
db.commit()
logger.info("Deleted mail source id=%s", _sanitize_for_log(source_id))
@router.post("/{source_id}/toggle", response_model=MailSourceResponse)
async def toggle_mail_source(
source_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> MailSourceResponse:
"""Toggle the *enabled* flag of a mail source."""
source = _get_source_or_404(source_id, db)
source.enabled = not source.enabled
source.updated_at = datetime.utcnow()
db.commit()
db.refresh(source)
return _source_to_response(source)
@router.post("/{source_id}/test", response_model=Dict[str, Any])
async def test_stored_mail_source(
source_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Test the connection for an already-stored mail source using its saved credentials."""
source = _get_source_or_404(source_id, db)
if source.method != "IMAP":
return {
"success": False,
"message": f"Connection testing for method '{source.method}' is not yet implemented.",
"timestamp": datetime.now().isoformat(),
}
imap_client = IMAPClient(
server=source.server,
port=source.port or 993,
username=source.username,
password=source.password,
)
success, message, stats = imap_client.test_connection()
if success:
source.last_checked = datetime.utcnow()
db.commit()
return {
"success": success,
"message": message,
"message_count": stats.get("message_count", 0),
"unread_count": stats.get("unread_count", 0),
"dmarc_count": stats.get("dmarc_count", 0),
"available_mailboxes": stats.get("available_mailboxes", []),
"timestamp": datetime.now().isoformat(),
}
@router.post("/test-connection", response_model=Dict[str, Any])
async def test_connection_adhoc(
request: TestConnectionRequest,
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""
Test a connection using ad-hoc credentials (not stored in the database).
Useful when filling out the *add/edit mail source* form before saving.
"""
method = request.method.upper()
if method != "IMAP":
return {
"success": False,
"message": f"Connection testing for method '{method}' is not yet implemented.",
"timestamp": datetime.now().isoformat(),
}
imap_client = IMAPClient(
server=request.server,
port=request.port,
username=request.username,
password=request.password,
)
success, message, stats = imap_client.test_connection()
return {
"success": success,
"message": message,
"message_count": stats.get("message_count", 0),
"unread_count": stats.get("unread_count", 0),
"dmarc_count": stats.get("dmarc_count", 0),
"available_mailboxes": stats.get("available_mailboxes", []),
"timestamp": datetime.now().isoformat(),
}