from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager from datetime import datetime from typing import Annotated from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from pydantic import BaseModel, Field from app.a2a import a2a_router from app.config import get_settings from app.core.service import CoreAgentService from app.security import AuthBackend settings = get_settings() logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("personal-agent") core_service = CoreAgentService(settings=settings, logger=logger) auth_backend = AuthBackend(settings=settings) scheduler: AsyncIOScheduler | None = None scan_lock: asyncio.Lock | None = None unsubscribe_lock: asyncio.Lock | None = None class ScanResponse(BaseModel): scanned: int linkedin: int advertising: int veille_techno: int skipped: int failed: int class AvailabilityRequest(BaseModel): start: str end: str calendar_ids: list[str] | None = None class BusySlot(BaseModel): calendar_id: str start: str end: str class AvailabilityResponse(BaseModel): start: str end: str available: bool busy_slots: list[BusySlot] checked_calendars: list[str] class UnsubscribeDigestResponse(BaseModel): scanned_messages: int extracted_unique_links: int new_links: int sent_to: str | None email_sent: bool class UnsubscribeMethodResponse(BaseModel): method_id: str method_type: str value: str class MailingListCandidateResponse(BaseModel): candidate_id: str list_name: str sender_domain: str message_count: int sample_senders: list[str] sample_subjects: list[str] methods: list[UnsubscribeMethodResponse] approved: bool class UnsubscribeCandidatesResponse(BaseModel): scanned_messages: int candidates: list[MailingListCandidateResponse] class ExecuteUnsubscribeRequest(BaseModel): selected_candidate_ids: list[str] = Field(default_factory=list) max_results: int | None = Field(default=None, ge=1, le=500) remember_selection: bool = True class MethodExecutionResponse(BaseModel): candidate_id: str list_name: str method_id: str method_type: str value: str success: bool detail: str class UnsubscribeExecutionResponse(BaseModel): scanned_messages: int candidates_considered: int selected_candidates: int executed_methods: int skipped_already_executed: int failed_methods: int updated_approved_count: int results: list[MethodExecutionResponse] def _is_api_auth_enabled() -> bool: return auth_backend.is_enabled() def require_scope(*required_scopes: str): scope_set = {scope.strip() for scope in required_scopes if scope.strip()} def _dependency( x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, authorization: Annotated[str | None, Header()] = None, ) -> None: auth_backend.authenticate( x_api_key=x_api_key, authorization=authorization, required_scopes=scope_set, ) return _dependency def _run_scan_once(max_results: int) -> ScanResponse: result = core_service.scan_mailbox(max_results=max_results) return ScanResponse( scanned=result.scanned, linkedin=result.linkedin, advertising=result.advertising, veille_techno=result.veille_techno, skipped=result.skipped, failed=result.failed, ) def _run_unsubscribe_digest_once(max_results: int) -> UnsubscribeDigestResponse: result = core_service.scan_unsubscribe_digest(max_results=max_results) return UnsubscribeDigestResponse( scanned_messages=result.scanned_messages, extracted_unique_links=result.extracted_unique_links, new_links=result.new_links, sent_to=result.sent_to, email_sent=result.email_sent, ) def _run_unsubscribe_candidates_once(max_results: int) -> UnsubscribeCandidatesResponse: result = core_service.list_unsubscribe_candidates(max_results=max_results) return UnsubscribeCandidatesResponse( scanned_messages=result.scanned_messages, candidates=[ MailingListCandidateResponse( candidate_id=candidate.candidate_id, list_name=candidate.list_name, sender_domain=candidate.sender_domain, message_count=candidate.message_count, sample_senders=candidate.sample_senders, sample_subjects=candidate.sample_subjects, methods=[ UnsubscribeMethodResponse( method_id=method.method_id, method_type=method.method_type, value=method.value, ) for method in candidate.methods ], approved=candidate.approved, ) for candidate in result.candidates ], ) def _run_unsubscribe_execute_selected( selected_candidate_ids: list[str], max_results: int, remember_selection: bool, ) -> UnsubscribeExecutionResponse: result = core_service.execute_unsubscribe_selected( selected_candidate_ids=selected_candidate_ids, max_results=max_results, remember_selection=remember_selection, ) return UnsubscribeExecutionResponse( scanned_messages=result.scanned_messages, candidates_considered=result.candidates_considered, selected_candidates=result.selected_candidates, executed_methods=result.executed_methods, skipped_already_executed=result.skipped_already_executed, failed_methods=result.failed_methods, updated_approved_count=result.updated_approved_count, results=[ MethodExecutionResponse( candidate_id=item.candidate_id, list_name=item.list_name, method_id=item.method_id, method_type=item.method_type, value=item.value, success=item.success, detail=item.detail, ) for item in result.results ], ) def _run_unsubscribe_auto_once(max_results: int) -> UnsubscribeExecutionResponse: result = core_service.run_unsubscribe_auto(max_results=max_results) return UnsubscribeExecutionResponse( scanned_messages=result.scanned_messages, candidates_considered=result.candidates_considered, selected_candidates=result.selected_candidates, executed_methods=result.executed_methods, skipped_already_executed=result.skipped_already_executed, failed_methods=result.failed_methods, updated_approved_count=result.updated_approved_count, results=[ MethodExecutionResponse( candidate_id=item.candidate_id, list_name=item.list_name, method_id=item.method_id, method_type=item.method_type, value=item.value, success=item.success, detail=item.detail, ) for item in result.results ], ) def _get_scan_lock() -> asyncio.Lock: global scan_lock if scan_lock is None: scan_lock = asyncio.Lock() return scan_lock def _get_unsubscribe_lock() -> asyncio.Lock: global unsubscribe_lock if unsubscribe_lock is None: unsubscribe_lock = asyncio.Lock() return unsubscribe_lock async def _scheduled_scan() -> None: lock = _get_scan_lock() if lock.locked(): logger.info("Previous scan still running, skipping this tick.") return async with lock: try: result = await asyncio.to_thread(_run_scan_once, 100) logger.info("Scheduled scan complete: %s", result.model_dump()) except Exception: logger.exception("Scheduled scan failed") async def _scheduled_unsubscribe_digest() -> None: lock = _get_unsubscribe_lock() if lock.locked(): logger.info("Previous unsubscribe digest still running, skipping this tick.") return async with lock: try: result = await asyncio.to_thread( _run_unsubscribe_digest_once, settings.unsubscribe_max_results ) logger.info("Scheduled unsubscribe digest complete: %s", result.model_dump()) except Exception: logger.exception("Scheduled unsubscribe digest failed") async def _scheduled_unsubscribe_auto() -> None: lock = _get_unsubscribe_lock() if lock.locked(): logger.info("Previous unsubscribe auto run still running, skipping this tick.") return async with lock: try: result = await asyncio.to_thread( _run_unsubscribe_auto_once, settings.unsubscribe_hil_max_results ) logger.info("Scheduled unsubscribe auto run complete: %s", result.model_dump()) except Exception: logger.exception("Scheduled unsubscribe auto run failed") @asynccontextmanager async def lifespan(app: FastAPI): # Startup global scheduler _get_scan_lock() _get_unsubscribe_lock() logger.info( "API authentication enabled=%s mode=%s (header: X-API-Key or Bearer token)", _is_api_auth_enabled(), settings.auth_mode, ) scheduler = AsyncIOScheduler() scheduler.add_job( # type: ignore _scheduled_scan, "interval", minutes=settings.gmail_scan_interval_minutes, next_run_time=datetime.now(), ) scheduler.add_job( # type: ignore _scheduled_unsubscribe_digest, "interval", minutes=settings.unsubscribe_digest_interval_minutes, next_run_time=datetime.now(), ) if settings.unsubscribe_auto_enabled: scheduler.add_job( # type: ignore _scheduled_unsubscribe_auto, "interval", minutes=settings.unsubscribe_auto_interval_minutes, next_run_time=datetime.now(), ) scheduler.start() # type: ignore logger.info( "Scheduler started (scan=%s min, digest=%s min, auto_unsub=%s/%s min)", settings.gmail_scan_interval_minutes, settings.unsubscribe_digest_interval_minutes, settings.unsubscribe_auto_enabled, settings.unsubscribe_auto_interval_minutes, ) yield # Shutdown if scheduler: scheduler.shutdown(wait=False) # type: ignore app = FastAPI(title="Personal Agent", version="0.3.0", lifespan=lifespan) # type: ignore app.include_router(a2a_router) @app.get("/health") def health() -> dict[str, object]: return { "status": "ok", "scan_interval_minutes": settings.gmail_scan_interval_minutes, "unsubscribe_digest_interval_minutes": settings.unsubscribe_digest_interval_minutes, "unsubscribe_auto_enabled": settings.unsubscribe_auto_enabled, "unsubscribe_auto_interval_minutes": settings.unsubscribe_auto_interval_minutes, } @app.post( "/scan", response_model=ScanResponse, dependencies=[Depends(require_scope("mail:scan"))], ) async def scan_now(max_results: int = Query(100, ge=1, le=500)) -> ScanResponse: async with _get_scan_lock(): try: return await asyncio.to_thread(_run_scan_once, max_results) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc), ) from exc except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Gmail scan failed: {exc}", ) from exc @app.post( "/availability", response_model=AvailabilityResponse, dependencies=[Depends(require_scope("availability:read"))], ) async def availability(request: AvailabilityRequest) -> AvailabilityResponse: try: result = await asyncio.to_thread( core_service.check_availability, request.start, request.end, request.calendar_ids, ) return AvailabilityResponse( start=result.start, end=result.end, available=result.available, busy_slots=[ BusySlot.model_validate(slot, from_attributes=True) for slot in result.busy_slots ], checked_calendars=result.checked_calendars, ) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc), ) from exc except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Availability lookup failed: {exc}", ) from exc @app.post( "/unsubscribe-digest", response_model=UnsubscribeDigestResponse, dependencies=[Depends(require_scope("unsubscribe:digest"))], ) async def unsubscribe_digest_now( max_results: int = Query(default=settings.unsubscribe_max_results, ge=1, le=500), ) -> UnsubscribeDigestResponse: async with _get_unsubscribe_lock(): try: return await asyncio.to_thread(_run_unsubscribe_digest_once, max_results) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc), ) from exc except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Unsubscribe digest failed: {exc}", ) from exc @app.post( "/unsubscribe/candidates", response_model=UnsubscribeCandidatesResponse, dependencies=[Depends(require_scope("unsubscribe:read"))], ) async def unsubscribe_candidates( max_results: int = Query(default=settings.unsubscribe_hil_max_results, ge=1, le=500), ) -> UnsubscribeCandidatesResponse: async with _get_unsubscribe_lock(): try: return await asyncio.to_thread(_run_unsubscribe_candidates_once, max_results) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Candidate discovery failed: {exc}", ) from exc @app.post( "/unsubscribe/execute", response_model=UnsubscribeExecutionResponse, dependencies=[Depends(require_scope("unsubscribe:execute"))], ) async def unsubscribe_execute(request: ExecuteUnsubscribeRequest) -> UnsubscribeExecutionResponse: max_results = request.max_results or settings.unsubscribe_hil_max_results async with _get_unsubscribe_lock(): try: return await asyncio.to_thread( _run_unsubscribe_execute_selected, request.selected_candidate_ids, max_results, request.remember_selection, ) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Unsubscribe execution failed: {exc}", ) from exc @app.post( "/unsubscribe/auto-run", response_model=UnsubscribeExecutionResponse, dependencies=[Depends(require_scope("unsubscribe:execute"))], ) async def unsubscribe_auto_run( max_results: int = Query(default=settings.unsubscribe_hil_max_results, ge=1, le=500), ) -> UnsubscribeExecutionResponse: async with _get_unsubscribe_lock(): try: return await asyncio.to_thread(_run_unsubscribe_auto_once, max_results) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Auto unsubscribe run failed: {exc}", ) from exc