121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""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)))
|