diff --git a/CHANGELOG.md b/CHANGELOG.md index baf61b7..cb199dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Unified Google OAuth flow**: Google Sign-In now requests all Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`) in the same consent screen, so users no longer need a separate "Connect Gmail" step after signing in with Google. Gmail credentials are stored automatically on successful sign-in. +- `include_granted_scopes=true` added to both the login and Gmail authorize URLs so scope additions take effect for users who previously connected. + +### Changed +- `GMAIL_API_SCOPES` (providers endpoint) and `GMAIL_SCOPES` (GmailService) now include `gmail.readonly`, required for `users().getProfile()` access verification (fixes 403 insufficientPermissions errors). +- Google Sign-In authorize URL (`GET /auth/google/authorize-url`) now requests all six scopes with `access_type=offline`, `prompt=consent`, and `include_granted_scopes=true` so a refresh token is always issued. +- Gmail "Connect Gmail" button in Settings now redirects to `/auth/callback?state=gmail_connect` instead of the dedicated `/auth/gmail-callback` page, reducing the number of redirect URIs that must be registered in Google Cloud Console to one (`{origin}/auth/callback`). +- `auth_service.py` `get_google_user_info` now returns `access_token`, `refresh_token`, `expires_in`, and `scope` alongside user info so the login endpoint can persist Gmail credentials in the same request. + ### Fixed +- Gmail API `verify_access()` returning 403 for tokens that lacked a read-capable scope: added `gmail.readonly` to all scope lists. + - Fixed three ESLint errors that caused CI to fail: removed unused `_setUser` store binding and unused `useAuthStore` import from `login/page.tsx`; replaced unused `_err` catch binding with a bare `catch {}` in `login/page.tsx`; removed a `useEffect` in `settings/page.tsx` that called `setProfileForm` synchronously (flagged by `react-hooks/set-state-in-effect`) — the effect was redundant because `useState` already initialises the form from the auth store's `user` object, which is the same value passed as `initialData` to `useQuery`. - Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content. - Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox. diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 142d554..e980b50 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -6,18 +6,32 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from urllib.parse import quote as urlquote import logging +from app.core.config import settings from app.core.database import get_db -from app.core.security import verify_password, get_password_hash -from app.models.database_models import User, SubscriptionTier +from app.core.security import verify_password, get_password_hash, encrypt_credential +from app.models.database_models import User, SubscriptionTier, GmailCredential from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest from app.services.auth_service import oauth_service +from app.services.gmail_service import GmailService, GMAIL_SCOPES router = APIRouter() logger = logging.getLogger(__name__) +# All scopes requested during Google Sign-In so users only go through one OAuth +# consent screen for both login and Gmail API access. +# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from +# gmail_service so the scope list stays in sync with what GmailService uses. +GOOGLE_LOGIN_SCOPES = [ + "openid", + "email", + "profile", + *GMAIL_SCOPES, +] + @router.post( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED @@ -154,6 +168,66 @@ async def google_oauth( await db.commit() await db.refresh(user) + # If Gmail tokens were returned (Gmail scopes were granted), store them so + # users don't need a separate "Connect Gmail" step after signing in. + google_access_token = user_info.get("access_token") + google_refresh_token = user_info.get("refresh_token") + if google_access_token: + try: + gmail_service = GmailService( + access_token=google_access_token, + refresh_token=google_refresh_token, + client_id=settings.GOOGLE_CLIENT_ID, + client_secret=settings.GOOGLE_CLIENT_SECRET, + ) + is_valid = await gmail_service.verify_access() + if is_valid: + token_expiry = datetime.now(timezone.utc) + timedelta( + seconds=int(user_info.get("expires_in", 3600)) + ) + encrypted_access = encrypt_credential(google_access_token) + encrypted_refresh = ( + encrypt_credential(google_refresh_token) + if google_refresh_token + else None + ) + scope_list = user_info.get("scope", "").split() + + cred_result = await db.execute( + select(GmailCredential).where(GmailCredential.user_id == user.id) + ) + existing_cred = cred_result.scalar_one_or_none() + + if existing_cred: + existing_cred.gmail_email = email # type: ignore[assignment] + existing_cred.encrypted_access_token = encrypted_access # type: ignore[assignment] + if encrypted_refresh: + existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] + existing_cred.token_expiry = token_expiry # type: ignore[assignment] + existing_cred.scopes = scope_list # type: ignore[assignment] + existing_cred.is_valid = True # type: ignore[assignment] + existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] + else: + new_cred = GmailCredential( + user_id=user.id, + gmail_email=email, + encrypted_access_token=encrypted_access, + encrypted_refresh_token=encrypted_refresh, + token_expiry=token_expiry, + scopes=scope_list, + is_valid=True, + last_verified_at=datetime.now(timezone.utc), + ) + db.add(new_cred) + + await db.commit() + logger.info(f"Gmail credentials stored for user: {email}") + except Exception as e: + # Non-fatal: login succeeds even if Gmail credential storage fails + logger.warning( + f"Could not store Gmail credentials during Google login for {email}: {e}" + ) + # Create tokens tokens = oauth_service.create_tokens_for_user(user) @@ -162,16 +236,17 @@ async def google_oauth( @router.get("/google/authorize-url") async def get_google_authorize_url(redirect_uri: str): - """Get Google OAuth2 authorization URL""" - from app.core.config import settings - + """Get Google OAuth2 authorization URL requesting all necessary scopes.""" + scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES)) auth_url = ( - f"https://accounts.google.com/o/oauth2/v2/auth?" - f"client_id={settings.GOOGLE_CLIENT_ID}&" - f"response_type=code&" - f"scope=openid%20email%20profile&" - f"redirect_uri={redirect_uri}&" - f"access_type=offline" + "https://accounts.google.com/o/oauth2/v2/auth" + f"?client_id={settings.GOOGLE_CLIENT_ID}" + "&response_type=code" + f"&scope={scope}" + f"&redirect_uri={redirect_uri}" + "&access_type=offline" + "&prompt=consent" + "&include_granted_scopes=true" ) return {"authorization_url": auth_url} diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index 6ef5ea5..480ef4a 100644 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta, timezone from typing import List, Optional +from urllib.parse import quote as urlquote from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -21,17 +22,18 @@ from app.models.schemas import ( GmailAuthorizeResponse, GmailCallbackRequest, ) -from app.services.gmail_service import GmailService +from app.services.gmail_service import GmailService, GMAIL_SCOPES router = APIRouter() logger = logging.getLogger(__name__) -# Gmail API scopes needed for email injection +# Gmail API scopes requested during the "Connect Gmail" OAuth flow. +# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from +# gmail_service so the scope list stays in sync with what GmailService uses. GMAIL_API_SCOPES = [ "openid", "email", - "https://www.googleapis.com/auth/gmail.insert", - "https://www.googleapis.com/auth/gmail.labels", + *GMAIL_SCOPES, ] # Provider presets with server configurations @@ -302,7 +304,7 @@ async def get_gmail_authorize_url( detail="Google OAuth2 is not configured on this server.", ) - scope = " ".join(GMAIL_API_SCOPES) + scope = urlquote(" ".join(GMAIL_API_SCOPES)) url = ( "https://accounts.google.com/o/oauth2/v2/auth" f"?client_id={settings.GOOGLE_CLIENT_ID}" @@ -311,6 +313,8 @@ async def get_gmail_authorize_url( f"&redirect_uri={redirect_uri}" "&access_type=offline" "&prompt=consent" + "&include_granted_scopes=true" + "&state=gmail_connect" ) return GmailAuthorizeResponse(authorization_url=url) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 5cd73a2..7253d05 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -25,12 +25,15 @@ class OAuthService: def _register_google(self): """Register Google OAuth2 provider""" if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET: + from app.services.gmail_service import GMAIL_SCOPES + + scope = " ".join(["openid", "email", "profile", *GMAIL_SCOPES]) self.oauth.register( name="google", client_id=settings.GOOGLE_CLIENT_ID, client_secret=settings.GOOGLE_CLIENT_SECRET, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, + client_kwargs={"scope": scope}, ) async def get_google_user_info( @@ -99,6 +102,10 @@ class OAuthService: "google_id": user_info.get("id"), "picture": user_info.get("picture"), "verified_email": user_info.get("verified_email", False), + "access_token": access_token, + "refresh_token": token_data.get("refresh_token"), + "expires_in": token_data.get("expires_in"), + "scope": token_data.get("scope", ""), } except HTTPException: diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index 717fba1..e6ba816 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) GMAIL_SCOPES = [ "https://www.googleapis.com/auth/gmail.insert", "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.readonly", ] diff --git a/docs/TODO.md b/docs/TODO.md index a0679b8..ee4e0f0 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -182,6 +182,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Account enable/disable toggle (UX + backend) - [x] Per-user SMTP configuration (UX + backend) - [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling +- [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console - [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking) - [ ] Implement GDPR data export endpoint - [ ] Complete notification service integration (Apprise) diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index 0f758d4..1e93f3c 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { authApi, userApi } from '@/lib/api'; +import { authApi, gmailApi, userApi } from '@/lib/api'; import { useAuthStore } from '@/store/authStore'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; @@ -17,6 +17,7 @@ function AuthCallbackContent() { const handleCallback = async () => { const code = searchParams.get('code'); const error = searchParams.get('error'); + const state = searchParams.get('state'); if (error) { setStatus('error'); @@ -32,8 +33,31 @@ function AuthCallbackContent() { return; } + const redirectUri = `${window.location.origin}/auth/callback`; + + // Gmail reconnect flow: user was already logged in and clicked + // "Connect Gmail" in Settings. The authorize URL includes state=gmail_connect. + if (state === 'gmail_connect') { + try { + await gmailApi.saveCallback(code, redirectUri); + setStatus('success'); + setMessage('Gmail connected successfully! Redirecting to settings…'); + setTimeout(() => router.push('/settings'), 1500); + } catch (err: unknown) { + const detail = + err instanceof Error && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data + ?.detail + : null; + setStatus('error'); + setMessage(detail || 'Failed to connect Gmail. Please try again.'); + setTimeout(() => router.push('/settings'), 3000); + } + return; + } + + // Normal Google Sign-In flow try { - const redirectUri = `${window.location.origin}/auth/callback`; const response = await authApi.googleAuth(code, redirectUri); localStorage.setItem('access_token', response.access_token); diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 13fc949..ab23cfd 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -175,7 +175,7 @@ function SettingsContent() { const handleConnectGmail = async () => { try { - const redirectUri = `${window.location.origin}/auth/gmail-callback`; + const redirectUri = `${window.location.origin}/auth/callback`; const url = await gmailApi.getAuthorizeUrl(redirectUri); window.location.href = url; } catch (error) {