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:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user