Merge pull request #76 from christianlouis/copilot/update-gmail-api-scopes

Expand Gmail OAuth scopes and unify Google callback URL
This commit is contained in:
Christian Krakau-Louis
2026-03-25 23:11:19 +01:00
committed by GitHub
8 changed files with 145 additions and 21 deletions
+12
View File
@@ -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.
+87 -12
View File
@@ -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}
+9 -5
View File
@@ -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)
+8 -1
View File
@@ -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:
+1
View File
@@ -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",
]
+1
View File
@@ -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)
+26 -2
View File
@@ -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);
+1 -1
View File
@@ -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) {