"""Gmail Pub/Sub push receiver. Google Pub/Sub push subscriptions deliver Gmail watch notifications as POST requests with a JSON envelope. The body payload contains a base64-encoded JSON blob with ``emailAddress`` + ``historyId``. We resolve the user by email, look up their cloud_scout_configs row for provider='gmail', and hand off to ScoutEngine.trigger_scout. Authentication: Pub/Sub push includes an OIDC JWT in the Authorization header. We verify it against Google's public keys with the audience configured in our Pub/Sub subscription. Dev mode: when ``GMAIL_PUBSUB_AUDIENCE`` is empty, JWT verification is skipped and a warning is logged. Production must set this env var. """ from __future__ import annotations import base64 import json import logging import uuid from fastapi import APIRouter, Header, HTTPException, Request, status from sqlalchemy import select from app.config.settings import settings from app.db import async_session from app.models import CloudScoutConfig, User from app.scouts.engine import ScoutEngine logger = logging.getLogger(__name__) router = APIRouter(prefix="/scouts/webhooks", tags=["scout-webhooks"]) def _verify_pubsub_jwt(token: str) -> bool: """Verify the Google Pub/Sub OIDC JWT. Returns True when valid, False on any verification failure. Dev skip: if ``settings.GMAIL_PUBSUB_AUDIENCE`` is empty, logs a warning and returns True so local development works without a real Pub/Sub subscription. Production must configure the audience. """ if not token: return False if not settings.GMAIL_PUBSUB_AUDIENCE: logger.warning( "GMAIL_PUBSUB_AUDIENCE not set — skipping Pub/Sub JWT verification (dev mode only)" ) return True try: from google.auth.transport import requests as g_requests # noqa: PLC0415 from google.oauth2 import id_token # noqa: PLC0415 id_token.verify_oauth2_token( token, g_requests.Request(), audience=settings.GMAIL_PUBSUB_AUDIENCE, ) return True except Exception: logger.warning("pubsub jwt verification failed", exc_info=True) return False @router.post("/gmail", status_code=status.HTTP_204_NO_CONTENT) async def gmail_pubsub( request: Request, authorization: str = Header(default=""), ) -> None: """Receive a Gmail Pub/Sub push notification. Verifies the OIDC JWT, decodes the Pub/Sub envelope, resolves the user by email, and triggers ScoutEngine.trigger_scout for each enabled Gmail scout belonging to that user. Returns 204 No Content on success (including benign no-ops like unknown email or empty message data). Returns 401 on JWT verification failure. """ token = authorization.removeprefix("Bearer ").strip() if not _verify_pubsub_jwt(token): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid Pub/Sub JWT") body = await request.json() msg = body.get("message") or {} raw = msg.get("data") if not raw: return # ack without action — empty message data try: decoded = json.loads(base64.b64decode(raw).decode()) except Exception: logger.warning("pubsub payload decode failed") return email = decoded.get("emailAddress") if not email: return async with async_session() as session: user_q = await session.execute(select(User).where(User.email == email)) user = user_q.scalar_one_or_none() if user is None: logger.info("pubsub: no user for %s — ignoring", email) return scouts_q = await session.execute( select(CloudScoutConfig).where( CloudScoutConfig.user_id == user.id, CloudScoutConfig.provider == "gmail", CloudScoutConfig.enabled == True, # noqa: E712 ) ) scouts = scouts_q.scalars().all() engine = ScoutEngine() for scout in scouts: await engine.trigger_scout(uuid.UUID(str(scout.id)))