"""Cloud provider integration utilities. Provides: * Shared message dataclasses (``EmailMessage``, ``ChatMessage``) used by both the Gmail and MS Graph clients and consumed by ``agent_runner``. * ``get_provider()`` — factory that returns the correct client given a provider name and decrypted OAuth credentials dict. * ``encrypt_token()`` / ``decrypt_token()`` — Fernet-based at-rest encryption for OAuth tokens stored in ``cloud_agent_configs``. Encryption rationale -------------------- Unlike user content (which is E2E-encrypted client-side and **never** decrypted server-side), OAuth tokens *must* be decrypted server-side because the backend makes provider API calls on behalf of the user. The Fernet key lives solely in ``OAUTH_ENCRYPTION_KEY`` env var — it is never returned to clients. """ from __future__ import annotations import json import logging from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING from cryptography.fernet import Fernet, InvalidToken from app.config.settings import settings if TYPE_CHECKING: from app.integrations.gmail import GmailClient from app.integrations.ms_graph import MSGraphClient logger = logging.getLogger(__name__) # ── Shared message types ────────────────────────────────────────────────── @dataclass class EmailMessage: """A single email message fetched from Gmail or Outlook.""" id: str subject: str sender: str body_text: str date: datetime labels: list[str] = field(default_factory=list) @property def as_text(self) -> str: """Return a human-readable text representation for LLM extraction.""" date_str = self.date.strftime("%Y-%m-%d %H:%M") labels_str = f" [{', '.join(self.labels)}]" if self.labels else "" return ( f"From: {self.sender}\n" f"Date: {date_str}{labels_str}\n" f"Subject: {self.subject}\n\n" f"{self.body_text}" ) @dataclass class ChatMessage: """A single Teams chat or channel message fetched from MS Graph.""" id: str content: str sender: str channel: str | None date: datetime @property def as_text(self) -> str: """Return a human-readable text representation for LLM extraction.""" date_str = self.date.strftime("%Y-%m-%d %H:%M") channel_str = f" [channel: {self.channel}]" if self.channel else "" return ( f"From: {self.sender}\n" f"Date: {date_str}{channel_str}\n\n" f"{self.content}" ) # ── Fernet helpers ──────────────────────────────────────────────────────── def _get_fernet() -> Fernet: """Return a ``Fernet`` instance using ``settings.OAUTH_ENCRYPTION_KEY``. Raises ``RuntimeError`` if ``OAUTH_ENCRYPTION_KEY`` is not set — callers must ensure this is configured before persisting OAuth tokens. """ key = settings.OAUTH_ENCRYPTION_KEY if not key: raise RuntimeError( "OAUTH_ENCRYPTION_KEY is not set. " "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" ) return Fernet(key.encode() if isinstance(key, str) else key) def encrypt_token(token_info: dict) -> str: """Fernet-encrypt an OAuth credential dict and return a base64 string. Stores the full ``{access_token, refresh_token, token_uri, client_id, client_secret, scopes, expiry}`` dict (or equivalent MSAL shape). Raises: RuntimeError: OAUTH_ENCRYPTION_KEY is not configured. ValueError: ``token_info`` is not a non-empty dict. """ if not isinstance(token_info, dict) or not token_info: raise ValueError("token_info must be a non-empty dict") plaintext = json.dumps(token_info).encode("utf-8") return _get_fernet().encrypt(plaintext).decode("utf-8") def decrypt_token(encrypted: str) -> dict: """Decrypt a Fernet-encrypted token string and return the credential dict. Raises: RuntimeError: OAUTH_ENCRYPTION_KEY is not configured. ValueError: The encrypted string is invalid or was encrypted with a different key. """ try: plaintext = _get_fernet().decrypt(encrypted.encode("utf-8")) return json.loads(plaintext) except (InvalidToken, json.JSONDecodeError) as exc: raise ValueError(f"Failed to decrypt OAuth token: {exc}") from exc # ── Provider factory ────────────────────────────────────────────────────── def get_provider( provider: str, credentials_info: dict, ) -> "GmailClient | MSGraphClient": """Return the correct provider client for *provider*. Parameters ---------- provider: One of ``"gmail"``, ``"outlook"``, ``"teams"``. credentials_info: Decrypted OAuth credential dict (Google or Microsoft shape). Raises: ValueError: Unknown provider name. """ if provider == "gmail": from app.integrations.gmail import GmailClient return GmailClient(credentials_info) if provider in {"outlook", "teams"}: from app.integrations.ms_graph import MSGraphClient return MSGraphClient(credentials_info) raise ValueError( f"Unknown cloud provider {provider!r}. " "Supported: 'gmail', 'outlook', 'teams'." )