From caa8eecd0a2a4189c0c37362b42bbe4237141b16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:34:52 +0000 Subject: [PATCH] feat: Gmail OAuth flow, per-user SMTP, message dedup, account disable toggle Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/b77800ca-b452-4427-b0bf-63403e906916 --- CHANGELOG.md | 12 +- backend/app/api/v1/endpoints/providers.py | 180 ++++++++- backend/app/api/v1/endpoints/users.py | 106 +++++- backend/app/models/database_models.py | 32 ++ backend/app/models/schemas.py | 32 ++ backend/app/services/gmail_service.py | 23 ++ backend/app/workers/tasks.py | 61 ++- docs/TODO.md | 11 +- frontend/src/app/auth/gmail-callback/page.tsx | 102 +++++ frontend/src/app/settings/page.tsx | 350 +++++++++++++++++- .../src/components/AddMailAccountModal.tsx | 13 + frontend/src/lib/api.ts | 79 ++++ 12 files changed, 986 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/auth/gmail-callback/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0482414..9629e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `sqlalchemy` from `2.0.25` to `2.0.48` to fix `AssertionError: Class ... directly inherits TypingOnly but has additional attributes` on Python 3.14 (`__static_attributes__`, `__firstlineno__`) ### Added -- `userApi.updateProfile()` method in `frontend/src/lib/api.ts` for updating user profile via `PUT /users/me` +- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again. +- **`is_enabled` checkbox in edit modal**: The Add/Edit mail account form now includes an "Enabled" checkbox so the flag can be set when creating or editing an account. +- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`. + - IMAP: messages are marked `\Seen` after fetching so they don't appear in future `UNSEEN` searches. DB UIDs provide a secondary guard. + - POP3: UIDL-based deduplication; messages are skipped if their UID is already in the DB. + - Old UID records are pruned by `cleanup_old_logs` after `days_to_keep` days. +- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints. The flow requests `gmail.insert + gmail.labels` scopes with `access_type=offline` so a long-lived refresh token is issued. A new `/auth/gmail-callback` frontend page handles the redirect from Google, exchanges the code, and redirects the user back to Settings. +- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run. If it did, the Celery task writes the new access token and expiry back to `GmailCredential`, eliminating an unnecessary extra refresh call on the next run. A `401/403` or `invalid_grant` error during delivery marks `GmailCredential.is_valid = False` so the user is prompted to re-authorise. +- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay (host, port, username, password, TLS flag). The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set. +- **Settings page β Gmail & SMTP sections**: The Settings page now shows a "Gmail API Delivery" card with connection status, "Connect Gmail" / "Re-authorise" / "Disconnect" buttons, and a token-lifetime explanation. An "SMTP Fallback" card lets users save their own SMTP relay credentials. +- **Celery scheduling fix**: `process_all_enabled_accounts` previously only polled accounts with `status IN [ACTIVE, TESTING]`, causing ERROR-status accounts to be silently skipped forever. It now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically. - **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` (e.g. `[proxy] BACKEND_URL = http://backend:8000`) via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors. The per-request error log now also includes the full target URL. - **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy - **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback) diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index d6603fd..6ef5ea5 100644 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -1,10 +1,12 @@ """Provider presets and Gmail credential management endpoints""" -from datetime import datetime, timezone -from typing import List +from datetime import datetime, timedelta, timezone +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +import httpx +import logging from app.core.database import get_db from app.core.deps import get_current_active_user @@ -16,10 +18,21 @@ from app.models.schemas import ( ProviderListResponse, GmailCredentialCreate, GmailCredentialResponse, + GmailAuthorizeResponse, + GmailCallbackRequest, ) from app.services.gmail_service import GmailService router = APIRouter() +logger = logging.getLogger(__name__) + +# Gmail API scopes needed for email injection +GMAIL_API_SCOPES = [ + "openid", + "email", + "https://www.googleapis.com/auth/gmail.insert", + "https://www.googleapis.com/auth/gmail.labels", +] # Provider presets with server configurations PROVIDER_PRESETS: List[ProviderPreset] = [ @@ -268,3 +281,166 @@ async def delete_gmail_credential( await db.delete(credential) await db.commit() + + +@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse) +async def get_gmail_authorize_url( + redirect_uri: str, + current_user: User = Depends(get_current_active_user), +): + """ + Return a Google OAuth2 URL that grants this app Gmail API write permissions. + + The URL requests the gmail.insert + gmail.labels scopes with + access_type=offline so a long-lived refresh token is issued. + prompt=consent forces Google to always issue a new refresh token even if + the user has authorised the app before. + """ + if not settings.GOOGLE_CLIENT_ID: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Google OAuth2 is not configured on this server.", + ) + + scope = " ".join(GMAIL_API_SCOPES) + url = ( + "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" + ) + return GmailAuthorizeResponse(authorization_url=url) + + +@router.post( + "/gmail/callback", + response_model=GmailCredentialResponse, + status_code=status.HTTP_201_CREATED, +) +async def gmail_oauth_callback( + callback_in: GmailCallbackRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """ + Exchange a Google authorization code for Gmail API tokens and persist them. + + This is the backend half of the "Connect Gmail" one-click flow. The + frontend redirects the user to Google, Google sends a code back to the + frontend callback page, and the frontend posts that code here. + + Token lifetime + -------------- + * Access token : 1 hour. The google-auth library auto-refreshes it + during Celery tasks; the new value is written back to this row so the + next run doesn't need an extra refresh round-trip. + * Refresh token : does not expire unless the user revokes access or the + app credentials change. If revoked, ``is_valid`` is set to False by + the next Celery run that hits a 401, and the user must re-authorise. + """ + if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Google OAuth2 is not configured on this server.", + ) + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_resp = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": callback_in.code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": callback_in.redirect_uri, + "grant_type": "authorization_code", + }, + ) + + if token_resp.status_code != 200: + logger.error(f"Gmail token exchange failed: {token_resp.text}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to exchange authorization code with Google.", + ) + + token_data = token_resp.json() + access_token: Optional[str] = token_data.get("access_token") + refresh_token: Optional[str] = token_data.get("refresh_token") + + if not access_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google did not return an access token.", + ) + + # Fetch the Gmail email address to associate with this credential + async with httpx.AsyncClient() as client: + profile_resp = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + gmail_email = current_user.email # fallback + if profile_resp.status_code == 200: + gmail_email = profile_resp.json().get("email", current_user.email) + + # Calculate token expiry (Google access tokens last 1 hour) + token_expiry = datetime.now(timezone.utc) + timedelta( + seconds=int(token_data.get("expires_in", 3600)) + ) + + # Verify the credentials actually work with the Gmail API + gmail_service = GmailService( + access_token=access_token, + refresh_token=refresh_token, + client_id=settings.GOOGLE_CLIENT_ID, + client_secret=settings.GOOGLE_CLIENT_SECRET, + ) + is_valid = await gmail_service.verify_access() + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Obtained tokens but could not verify Gmail API access. " + "Ensure the gmail.insert scope was granted.", + ) + + # Persist tokens + result = await db.execute( + select(GmailCredential).where(GmailCredential.user_id == current_user.id) + ) + existing = result.scalar_one_or_none() + + encrypted_access = encrypt_credential(access_token) + encrypted_refresh = encrypt_credential(refresh_token) if refresh_token else None + + if existing: + existing.gmail_email = gmail_email # type: ignore[assignment] + existing.encrypted_access_token = encrypted_access # type: ignore[assignment] + if encrypted_refresh: + existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] + existing.token_expiry = token_expiry # type: ignore[assignment] + existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment] + existing.is_valid = True # type: ignore[assignment] + existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] + await db.commit() + await db.refresh(existing) + return existing + else: + credential = GmailCredential( + user_id=current_user.id, + gmail_email=gmail_email, + encrypted_access_token=encrypted_access, + encrypted_refresh_token=encrypted_refresh, + token_expiry=token_expiry, + scopes=token_data.get("scope", "").split(), + is_valid=True, + last_verified_at=datetime.now(timezone.utc), + ) + db.add(credential) + await db.commit() + await db.refresh(credential) + return credential diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index a609652..60c05be 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,12 +1,19 @@ """User management endpoints""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from app.core.database import get_db from app.core.deps import get_current_active_user -from app.models.database_models import User -from app.models.schemas import UserDetailResponse, UserUpdate +from app.core.security import encrypt_credential +from app.models.database_models import User, UserSmtpConfig +from app.models.schemas import ( + UserDetailResponse, + UserUpdate, + UserSmtpConfigUpdate, + UserSmtpConfigResponse, +) router = APIRouter() @@ -34,3 +41,96 @@ async def update_current_user_profile( await db.commit() await db.refresh(current_user) return current_user + + +@router.get("/smtp-config", response_model=UserSmtpConfigResponse) +async def get_smtp_config( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """Get the current user's SMTP relay configuration""" + result = await db.execute( + select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id) + ) + config = result.scalar_one_or_none() + + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No SMTP configuration found. Save one first.", + ) + + return UserSmtpConfigResponse( + id=config.id, # type: ignore[arg-type] + user_id=config.user_id, # type: ignore[arg-type] + host=config.host, # type: ignore[arg-type] + port=config.port, # type: ignore[arg-type] + username=config.username, # type: ignore[arg-type] + use_tls=config.use_tls, # type: ignore[arg-type] + has_password=bool(config.encrypted_password), + created_at=config.created_at, # type: ignore[arg-type] + updated_at=config.updated_at, # type: ignore[arg-type] + ) + + +@router.put("/smtp-config", response_model=UserSmtpConfigResponse) +async def upsert_smtp_config( + config_in: UserSmtpConfigUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """Create or update the current user's SMTP relay configuration""" + result = await db.execute( + select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id) + ) + config = result.scalar_one_or_none() + + if config: + config.host = config_in.host # type: ignore[assignment] + config.port = config_in.port # type: ignore[assignment] + config.username = config_in.username # type: ignore[assignment] + config.use_tls = config_in.use_tls # type: ignore[assignment] + if config_in.password is not None: + config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment] + else: + config = UserSmtpConfig( + user_id=current_user.id, + host=config_in.host, + port=config_in.port, + username=config_in.username, + encrypted_password=( + encrypt_credential(config_in.password) if config_in.password else "" + ), + use_tls=config_in.use_tls, + ) + db.add(config) + + await db.commit() + await db.refresh(config) + + return UserSmtpConfigResponse( + id=config.id, # type: ignore[arg-type] + user_id=config.user_id, # type: ignore[arg-type] + host=config.host, # type: ignore[arg-type] + port=config.port, # type: ignore[arg-type] + username=config.username, # type: ignore[arg-type] + use_tls=config.use_tls, # type: ignore[arg-type] + has_password=bool(config.encrypted_password), + created_at=config.created_at, # type: ignore[arg-type] + updated_at=config.updated_at, # type: ignore[arg-type] + ) + + +@router.delete("/smtp-config", status_code=status.HTTP_204_NO_CONTENT) +async def delete_smtp_config( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """Delete the current user's SMTP relay configuration""" + result = await db.execute( + select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id) + ) + config = result.scalar_one_or_none() + if config: + await db.delete(config) + await db.commit() diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py index f460239..7932792 100644 --- a/backend/app/models/database_models.py +++ b/backend/app/models/database_models.py @@ -446,6 +446,38 @@ class AuditLog(Base): ) +class UserSmtpConfig(Base): + """Per-user SMTP relay configuration for email forwarding fallback.""" + + __tablename__ = "user_smtp_configs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True + ) + + host = Column(String(255), nullable=False, default="smtp.gmail.com") + port = Column(Integer, nullable=False, default=587) + username = Column(String(255), nullable=False, default="") + encrypted_password = Column(Text, nullable=False, default="") + use_tls = Column(Boolean, default=True) + + created_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Relationships + user = relationship("User", backref="smtp_config") + + class DownloadedMessageId(Base): """ Tracks unique message IDs that have already been downloaded and forwarded. diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 8261291..6321eb0 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -339,3 +339,35 @@ class ProviderPreset(BaseModel): class ProviderListResponse(BaseModel): providers: List[ProviderPreset] + + +# User SMTP Config Schemas +class UserSmtpConfigBase(BaseModel): + host: str = "smtp.gmail.com" + port: int = Field(587, gt=0, lt=65536) + username: str = "" + use_tls: bool = True + + +class UserSmtpConfigUpdate(UserSmtpConfigBase): + password: Optional[str] = None # Only provided when changing the password + + +class UserSmtpConfigResponse(UserSmtpConfigBase): + id: int + user_id: int + has_password: bool # True if a password is stored (value is never returned) + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +# Gmail OAuth Schemas +class GmailAuthorizeResponse(BaseModel): + authorization_url: str + + +class GmailCallbackRequest(BaseModel): + code: str + redirect_uri: str diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index 403bf46..717fba1 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -56,6 +56,7 @@ class GmailService: client_id: Google OAuth2 client ID client_secret: Google OAuth2 client secret """ + self._initial_access_token = access_token self.credentials = Credentials( token=access_token, refresh_token=refresh_token, @@ -135,6 +136,7 @@ class GmailService: f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}" ) logger.error(error_msg) + # Surface 401 so callers can mark credentials as invalid raise GmailInjectionError(error_msg) except Exception as e: error_msg = f"Failed to inject email into Gmail: {str(e)}" @@ -180,3 +182,24 @@ class GmailService: except Exception as e: logger.error(f"Failed to get Gmail email address: {e}") return None + + def get_refreshed_token(self) -> Optional[Dict[str, Any]]: + """ + Return the current access token and expiry if the token was refreshed + since this service instance was created. + + The google-auth library auto-refreshes the access token when an API + call is made with an expired token. Call this after inject_email() to + check whether a refresh happened and persist the new token. + + Returns: + Dict with ``access_token`` and ``expiry`` (datetime | None), or + None if the token has not changed from the one passed to __init__. + """ + current_token = self.credentials.token + if current_token and current_token != self._initial_access_token: + return { + "access_token": current_token, + "expiry": self.credentials.expiry, + } + return None diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index bd33f00..da4891a 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -9,7 +9,7 @@ import logging from app.workers.celery_app import celery_app from app.core.database import async_session_maker -from app.core.security import decrypt_credential +from app.core.security import decrypt_credential, encrypt_credential from app.models.database_models import ( MailAccount, ProcessingRun, @@ -18,12 +18,13 @@ from app.models.database_models import ( DeliveryMethod, GmailCredential, DownloadedMessageId, + UserSmtpConfig, ) from app.services.mail_processor import MailProcessor from app.services.gmail_service import GmailService from app.services.config_service import ConfigService from app.core.config import settings -from sqlalchemy import select, and_, delete +from sqlalchemy import select, delete logger = logging.getLogger(__name__) @@ -130,8 +131,24 @@ async def process_mail_account(account_id: int): use_gmail_api = False # type: ignore[assignment] if not use_gmail_api: - # Fall back to SMTP β read config from DB with env fallback - smtp_config = await ConfigService.get_smtp_config(db=db) + # Fall back to SMTP β check per-user config first, then global + user_smtp_result = await db.execute( + select(UserSmtpConfig).where( + UserSmtpConfig.user_id == account.user_id + ) + ) + user_smtp = user_smtp_result.scalar_one_or_none() + + if user_smtp and user_smtp.username and user_smtp.encrypted_password: + smtp_config = { + "host": user_smtp.host, + "port": user_smtp.port, + "username": user_smtp.username, + "password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type] + "use_tls": user_smtp.use_tls, + } + else: + smtp_config = await ConfigService.get_smtp_config(db=db) if not smtp_config["username"] or not smtp_config["password"]: logger.error( @@ -144,6 +161,12 @@ async def process_mail_account(account_id: int): successfully_forwarded_uids: list[str] = [] + if len(emails) != len(new_uids): + logger.error( + f"emails/uids length mismatch ({len(emails)} vs {len(new_uids)}) " + f"for account {account.id}; truncating to shorter list" + ) + for email_data, uid in zip(emails, new_uids): try: if use_gmail_api and gmail_service: @@ -167,6 +190,23 @@ async def process_mail_account(account_id: int): emails_failed += 1 except Exception as e: + error_str = str(e).lower() + # If Gmail returns 401/403 the refresh token was revoked β + # mark credentials invalid so the user gets notified. + if ( + use_gmail_api + and gmail_cred + and ( + "401" in error_str + or "403" in error_str + or "invalid_grant" in error_str + ) + ): + gmail_cred.is_valid = False # type: ignore[assignment] + logger.warning( + f"Gmail credentials revoked for user {account.user_id}. " + "User must re-authorise." + ) logger.error(f"Error delivering email: {e}") emails_failed += 1 @@ -180,6 +220,19 @@ async def process_mail_account(account_id: int): ) ) + # If Gmail API was used, persist any refreshed access token back to + # the DB so the next run doesn't need an extra token-refresh call. + if use_gmail_api and gmail_service and gmail_cred: + refreshed = gmail_service.get_refreshed_token() + if refreshed and refreshed["access_token"] != access_token: + gmail_cred.encrypted_access_token = encrypt_credential(refreshed["access_token"]) # type: ignore[assignment] + if refreshed.get("expiry"): + gmail_cred.token_expiry = refreshed["expiry"] # type: ignore[assignment] + gmail_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] + logger.info( + f"Persisted refreshed Gmail access token for user {account.user_id}" + ) + # Update run run.emails_forwarded = emails_forwarded # type: ignore[assignment] run.emails_failed = emails_failed # type: ignore[assignment] diff --git a/docs/TODO.md b/docs/TODO.md index 2b82d60..d1e9fd1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -179,6 +179,10 @@ Comprehensive task breakdown for repository improvements and production readines ### Not Started π - [ ] Implement Stripe webhook handling - [ ] Add scheduled Celery tasks for email processing +- [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] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking) - [ ] Implement GDPR data export endpoint - [ ] Complete notification service integration (Apprise) - [ ] Add advanced email filtering @@ -207,12 +211,13 @@ because the API client layer is missing. - [x] Registration page - [x] OAuth callback handler - [x] Dashboard with stats cards and processing runs table -- [x] Mail accounts list with CRUD operations -- [x] Settings page -- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields) +- [x] Mail accounts list with CRUD operations + enable/disable toggle +- [x] Settings page β Profile, Gmail API connection, SMTP relay, Account info, Security +- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields, is_enabled checkbox) - [x] `DashboardLayout` with responsive sidebar - [x] `AuthGuard` for protected routes - [x] Fix wizard grey screen (Tailwind v4 `bg-opacity` β `/75` syntax, modal restructure) +- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow ### Not Started π - [ ] End-to-end testing of frontend against backend API diff --git a/frontend/src/app/auth/gmail-callback/page.tsx b/frontend/src/app/auth/gmail-callback/page.tsx new file mode 100644 index 0000000..923eed5 --- /dev/null +++ b/frontend/src/app/auth/gmail-callback/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { gmailApi } from '@/lib/api'; +import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; + +function GmailCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState('Connecting your Gmail accountβ¦'); + + useEffect(() => { + async function handleCallback() { + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + if (error) { + setStatus('error'); + setMessage( + error === 'access_denied' + ? 'You declined the Gmail permission request. No changes were made.' + : `Google returned an error: ${error}` + ); + return; + } + + if (!code) { + setStatus('error'); + setMessage('No authorization code received from Google.'); + return; + } + + const redirectUri = `${window.location.origin}/auth/gmail-callback`; + + try { + await gmailApi.saveCallback(code, redirectUri); + setStatus('success'); + setMessage('Gmail connected successfully! Redirecting to settingsβ¦'); + setTimeout(() => router.push('/settings'), 2000); + } 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.'); + } + } + + handleCallback(); + }, [searchParams, router]); + + return ( +
{message}
+ > + )} + {status === 'success' && ( + <> +{message}
+ > + )} + {status === 'error' && ( + <> +{message}
+ + > + )} ++ Grant this app permission to inject emails directly into your Gmail inbox. + This is the preferred delivery method β emails arrive with original headers + intact, bypassing SMTP entirely. +
++ Token lifetime: Access tokens expire after 1 hour and are + refreshed automatically. Refresh tokens do not expire unless you revoke + access via your{' '} + + Google Account permissions + + . If revoked, click “Connect Gmail” again to re-authorise. +
+ + {gmailLoading && ( ++ Connected as {gmailCredential.gmail_email} +
+ {gmailCredential.last_verified_at && ( ++ Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()} +
+ )} ++ Used when Gmail API is not connected or when a mail account is configured + to use SMTP delivery. Your credentials are stored encrypted. +
+ + {smtpLoading ? ( +