"""Gmail account management endpoints.""" import secrets from datetime import datetime, timezone from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_current_user_csrf, get_db, require_csrf from app.core.security import log_audit from app.models import GmailAccount, OAuthClientConfig, OAuthToken, User from app.services import gmail_service router = APIRouter(prefix="/gmail", tags=["gmail-accounts"]) class AccountUpdate(BaseModel): sending_method: str = "api" # api | smtp daily_limit: int = 200 campaign_limit: int = 200 def _account_out(account: GmailAccount) -> dict: t = account.oauth_token return { "id": account.id, "email": account.email, "display_name": account.display_name, "status": account.status, "sending_method": account.sending_method, "daily_limit": account.daily_limit, "campaign_limit": account.campaign_limit, "sends_today": account.sends_today, "last_success_at": account.last_success_at.isoformat() if account.last_success_at else None, "error_count": account.error_count, "last_error": (account.last_error or "")[:300], "cooldown_until": account.cooldown_until.isoformat() if account.cooldown_until else None, "has_refresh_token": bool(t and t.refresh_token_enc), "token_expires_at": t.expires_at.isoformat() if t and t.expires_at else None, "authorized": account.status == "authorized", } @router.get("/accounts") async def list_accounts(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): rows = (await db.execute(select(GmailAccount).where( GmailAccount.user_id == user.id).order_by(GmailAccount.id))).scalars().all() return {"accounts": [_account_out(a) for a in rows], "count": len(rows)} @router.post("/accounts/connect") async def connect_start(user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db)): """Return the Google authorization URL for this user to open (real OAuth start). Uses the user's most recently imported client config when available.""" from sqlalchemy import select as _select cfg_row = (await db.execute( _select(OAuthClientConfig) .where(OAuthClientConfig.user_id == user.id, OAuthClientConfig.is_active == True) # noqa .order_by(OAuthClientConfig.id.desc()))).scalars().first() if cfg_row is not None: client = gmail_service.resolve_client_config(cfg_row) else: try: client = gmail_service.resolve_client_config() except gmail_service.GmailOauthError: raise HTTPException(400, "No Google OAuth client configuration imported yet. " "Use 'Import Gmail Configuration' first.") from app.api.routers.auth import google_start_impl return await google_start_impl(user, client) @router.post("/accounts/{account_id}/test") async def test_account(account_id: int, user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db)): """Real connection test: verify OAuth creds and Gmail API profile.""" account = await db.get(GmailAccount, account_id) if not account or account.user_id != user.id: raise HTTPException(404, "Account not found") token = account.oauth_token if not token: return {"ok": False, "detail": "No OAuth token stored for this account"} from app.db.session import SyncSession with SyncSession() as s: acc_sync = s.get(GmailAccount, account.id) tok_sync = s.get(OAuthToken, token.id) try: client = gmail_service.build_gmail_client_from_account(acc_sync, tok_sync) result = client.verify_connection() except Exception as e: result = {"ok": False, "detail": str(e)[:400]} if result.get("ok"): account.status = "authorized" await db.commit() else: account.status = "expired" if "auth" in (result.get("detail") or "").lower() else account.status account.last_error = (result.get("detail") or "")[:500] await db.commit() await log_audit(db, user.id, "gmail.test", "gmail_account", account.id, {"ok": result.get("ok")}, None) return result @router.post("/accounts/{account_id}/reauthorize") async def reauthorize(account_id: int, user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db)): account = await db.get(GmailAccount, account_id) if not account or account.user_id != user.id: raise HTTPException(404, "Account not found") try: client = gmail_service.resolve_client_config_for_account(account) except gmail_service.GmailOauthError as e: raise HTTPException(400, str(e)) state = gmail_service.generate_state() import json as _json import redis as _redis from app.core.config import settings try: r = _redis.from_url(settings.REDIS_URL, socket_timeout=2) r.setex(f"oauthstate:{state}", 600, _json.dumps({"user_id": user.id})) except Exception: raise HTTPException(503, "State store unavailable") url = gmail_service.build_authorization_url(client, state) return {"authorization_url": url} @router.post("/accounts/{account_id}/revoke") async def disconnect_account(account_id: int, user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db), request: Request = None): """Disconnect: revoke token at Google and delete stored credentials.""" account = await db.get(GmailAccount, account_id) if not account or account.user_id != user.id: raise HTTPException(404, "Account not found") token = account.oauth_token revoked_at_google = False if token: from app.services.gmail_service import revoke_token revoked_at_google = revoke_token(token.refresh_token or token.access_token) account.status = "revoked" account.is_active = False account.revoked_by_user = True account.revoked_at = datetime.now(timezone.utc) if token: await db.delete(token) await db.commit() await log_audit(db, user.id, "gmail.disconnect", "gmail_account", account.id, {"email": account.email, "revoked_at_google": revoked_at_google}, request.client.host if request else None) return {"ok": True, "revoked_at_google": revoked_at_google} @router.delete("/accounts/{account_id}") async def delete_account(account_id: int, user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db), request: Request = None): """DELETE semantics: revoke token at Google (best-effort) and REMOVE the account row (oauth_tokens cascade; campaign_recipients/send_* FKs are SET NULL).""" account = await db.get(GmailAccount, account_id) if not account or account.user_id != user.id: raise HTTPException(404, "Account not found") revoked_at_google = False token = account.oauth_token if token: revoked_at_google = gmail_service.revoke_token(token.refresh_token or token.access_token) email = account.email await db.delete(account) # token row cascades (delete-orphan + FK CASCADE) await db.commit() await log_audit(db, user.id, "gmail.delete", "gmail_account", account_id, {"email": email, "revoked_at_google": revoked_at_google}, request.client.host if request else None) return {"ok": True, "removed": True, "revoked_at_google": revoked_at_google} @router.post("/accounts/clear-all") async def clear_all_accounts(user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db), request: Request = None): """Clear All: revoke tokens at Google (best-effort) and remove all locally saved Gmail account rows. Audit trail preserved.""" rows = (await db.execute(select(GmailAccount).where( GmailAccount.user_id == user.id))).scalars().all() revoked = 0 for account in rows: token = account.oauth_token if token: if gmail_service.revoke_token(token.refresh_token or token.access_token): revoked += 1 await db.delete(account) # token cascades; related FKs are SET NULL await db.commit() await log_audit(db, user.id, "gmail.clear_all", "gmail_account", None, {"count": len(rows), "revoked_at_google": revoked}, request.client.host if request else None) return {"ok": True, "removed": len(rows), "revoked_at_google": revoked} @router.post("/accounts/import-config") async def import_client_config(file: UploadFile = File(...), user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db), request: Request = None): """Import & validate a Google OAuth client_secret JSON (never echoed back).""" from app.core.config import settings content = await file.read() if len(content) > 1024 * 512: raise HTTPException(413, "File too large (max 512 KB)") if not file.filename.lower().endswith(".json"): raise HTTPException(400, "Only .json configuration files are accepted") try: parsed = gmail_service.parse_client_json(content) except gmail_service.GmailOauthError as e: raise HTTPException(400, str(e)) row = OAuthClientConfig( name=parsed["project_id"] or file.filename[:100], user_id=user.id, client_id=parsed["client_id"], client_secret=parsed["client_secret"], # encrypted via property setter project_id=parsed["project_id"], redirect_uri=f"{settings.BASE_URL}/auth/google/callback", ) db.add(row) await db.commit() await log_audit(db, user.id, "oauth_config.imported", "oauth_client_config", row.id, {"project": parsed["project_id"]}, request.client.host if request else None) return {"ok": True, "id": row.id, "project_id": parsed["project_id"], "client_id_preview": parsed["client_id"][:12] + "…", "redirect_uri": row.redirect_uri} @router.patch("/accounts/{account_id}") async def update_account(account_id: int, payload: AccountUpdate, user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db)): account = await db.get(GmailAccount, account_id) if not account or account.user_id != user.id: raise HTTPException(404, "Account not found") if payload.sending_method in ("api", "smtp"): account.sending_method = payload.sending_method account.daily_limit = max(1, payload.daily_limit) account.campaign_limit = max(1, payload.campaign_limit) await db.commit() return _account_out(account)