GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke" GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" class GmailOauthError(Exception): pass # --------------------------------------------------------------------------- # OAuth client configuration resolution # --------------------------------------------------------------------------- def resolve_client_config(config=None) -> dict: """Return {client_id, client_secret, redirect_uri} for OAuth flows. Priority: explicit config dict (per-user imported config) > DB row > env file path > error. """ if config and config.client_id: return { "client_id": config.client_id, "client_secret": config.client_secret, # decrypted property "redirect_uri": config.redirect_uri or f"{settings.BASE_URL}/auth/google/callback", } path = settings.GOOGLE_OAUTH_CLIENT_JSON_PATH if path and __import__("os").path.exists(path): with open(path) as f: data = json.load(f) inst = data.get("installed") or data.get("web") if not inst: raise GmailOauthError("Invalid Google OAuth client JSON: no 'installed' or 'web' key") return { "client_id": inst["client_id"], "client_secret": inst["client_secret"], "redirect_uri": f"{settings.BASE_URL}/auth/google/callback", } raise GmailOauthError( "No Google OAuth client configuration available. Import a client_secret.json in " "Gmail Accounts → Import Gmail Configuration, or set GOOGLE_OAUTH_CLIENT_JSON_PATH." ) def parse_client_json(content: bytes) -> dict: """Validate an uploaded client_secret JSON. Returns normalized dict; raises on invalid.""" try: data = json.loads(content.decode("utf-8")) except Exception as e: raise GmailOauthError(f"File is not valid JSON: {e}") inst = data.get("installed") or data.get("web") if not inst or not inst.get("client_id") or not inst.get("client_secret"): raise GmailOauthError("JSON must contain an 'installed' or 'web' object with client_id and client_secret") return { "project_id": inst.get("project", "") or inst.get("project_id", ""), "client_id": inst["client_id"], "client_secret": inst["client_secret"], "redirect_uris": inst.get("redirect_uris", []), "auth_uri": inst.get("auth_uri", ""), "token_uri": inst.get("token_uri", ""), } # --------------------------------------------------------------------------- # Authorization flow # --------------------------------------------------------------------------- def generate_state() -> str: return secrets.token_urlsafe(32) def build_authorization_url(client: dict, state: str) -> str: params = { "client_id": client["client_id"], "redirect_uri": client["redirect_uri"], "response_type": "code", "scope": " ".join(ALL_SCOPES), "access_type": "offline", # needed to obtain a refresh token "prompt": "consent", # ensure refresh token is granted "include_granted_scopes": "true", "state": state, } return f"{GOOGLE_AUTH_URL}?{urlencode(params)}" def exchange_code_for_tokens(client: dict, code: str) -> dict: """Exchange authorization code for tokens. Real HTTP call to Google.""" data = { "code": code, "client_id": client["client_id"], "client_secret": client["client_secret"], "redirect_uri": client["redirect_uri"], "grant_type": "authorization_code", } resp = requests.post(GOOGLE_TOKEN_URL, data=data, timeout=20) if resp.status_code != 200: raise GmailOauthError(f"Token exchange failed: {resp.status_code} {resp.text[:300]}") tok = resp.json() if "access_token" not in tok: raise GmailOauthError("Token response missing access_token") return tok def refresh_access_token(client: dict, refresh_token: str) -> dict: data = { "client_id": client["client_id"], "client_secret": client["client_secret"], "refresh_token": refresh_token, "grant_type": "refresh_token", } resp = requests.post(GOOGLE_TOKEN_URL, data=data, timeout=20) if resp.status_code != 200: raise GmailOauthError(f"Token refresh failed: {resp.status_code} {resp.text[:300]}") return resp.json() def revoke_token(token: str) -> bool: """Revoke a token at Google. Returns True if revoked successfully.""" if not token: return False try: resp = requests.post(GOOGLE_REVOKE_URL, params={"token": token}, timeout=15) return resp.status_code == 200 except Exception: return False def get_userinfo(access_token: str) -> dict: resp = requests.get(GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"}, timeout=15) if resp.status_code != 200: raise GmailOauthError(f"userinfo failed: {resp.status_code}") return resp.json() # --------------------------------------------------------------------------- # Gmail client construction # --------------------------------------------------------------------------- class GmailClient: """Authenticated Gmail client for one account (API or XOAUTH2 SMTP)."""