84117abd13
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/07b58025-2aeb-4e81-a168-7a6fccdc3569 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
311 lines
9.7 KiB
Python
311 lines
9.7 KiB
Python
"""
|
||
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(),
|
||
}
|