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
This commit is contained in:
copilot-swe-agent[bot]
2026-03-25 11:34:52 +00:00
parent a15216c630
commit caa8eecd0a
12 changed files with 986 additions and 15 deletions
+11 -1
View File
@@ -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)
+178 -2
View File
@@ -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
+103 -3
View File
@@ -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()
+32
View File
@@ -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.
+32
View File
@@ -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
+23
View File
@@ -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
+57 -4
View File
@@ -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]
+8 -3
View File
@@ -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
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md w-full text-center">
{status === 'loading' && (
<>
<Loader2 className="h-12 w-12 text-blue-500 animate-spin mx-auto mb-4" />
<p className="text-gray-700">{message}</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connected!</h2>
<p className="text-gray-600">{message}</p>
</>
)}
{status === 'error' && (
<>
<XCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connection failed</h2>
<p className="text-gray-600 mb-6">{message}</p>
<button
onClick={() => router.push('/settings')}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Back to Settings
</button>
</>
)}
</div>
</div>
);
}
export default function GmailCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
}
>
<GmailCallbackContent />
</Suspense>
);
}
+348 -2
View File
@@ -4,9 +4,18 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { userApi } from '@/lib/api';
import { userApi, gmailApi, smtpApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { CheckCircle, Loader2, User, Mail, Shield } from 'lucide-react';
import {
CheckCircle,
Loader2,
User,
Mail,
Shield,
Server,
AlertTriangle,
XCircle,
} from 'lucide-react';
export default function SettingsPage() {
return (
@@ -28,6 +37,16 @@ function SettingsContent() {
});
const [profileSaved, setProfileSaved] = useState(false);
// SMTP form state
const [smtpForm, setSmtpForm] = useState({
host: 'smtp.gmail.com',
port: 587,
username: '',
password: '',
use_tls: true,
});
const [smtpSaved, setSmtpSaved] = useState(false);
// Refresh user data from the server
const { data: currentUser } = useQuery({
queryKey: ['current-user'],
@@ -35,6 +54,38 @@ function SettingsContent() {
initialData: user ?? undefined,
});
// Gmail credential status
const {
data: gmailCredential,
isLoading: gmailLoading,
error: gmailError,
} = useQuery({
queryKey: ['gmail-credential'],
queryFn: gmailApi.getCredential,
retry: false,
});
// SMTP config
const { data: smtpConfig, isLoading: smtpLoading } = useQuery({
queryKey: ['smtp-config'],
queryFn: smtpApi.get,
retry: false,
});
// Pre-populate SMTP form when data loads
const [smtpFormPopulated, setSmtpFormPopulated] = useState(false);
if (smtpConfig && !smtpFormPopulated) {
setSmtpFormPopulated(true);
setSmtpForm((prev) => ({
...prev,
host: smtpConfig.host,
port: smtpConfig.port,
username: smtpConfig.username,
use_tls: smtpConfig.use_tls,
password: '', // never pre-fill password
}));
}
const updateProfileMutation = useMutation({
mutationFn: (data: { full_name: string; email: string }) =>
userApi.updateProfile(data),
@@ -46,11 +97,45 @@ function SettingsContent() {
},
});
const disconnectGmailMutation = useMutation({
mutationFn: gmailApi.disconnect,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
},
});
const saveSmtpMutation = useMutation({
mutationFn: smtpApi.save,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smtp-config'] });
setSmtpSaved(true);
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const deleteSmtpMutation = useMutation({
mutationFn: smtpApi.remove,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smtp-config'] });
setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', password: '', use_tls: true });
},
});
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProfileForm((prev) => ({ ...prev, [name]: value }));
};
const handleSmtpChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setSmtpForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
: name === 'port' ? Number(value)
: value,
}));
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
@@ -68,7 +153,55 @@ function SettingsContent() {
}
};
const handleSmtpSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await saveSmtpMutation.mutateAsync({
host: smtpForm.host,
port: smtpForm.port,
username: smtpForm.username,
password: smtpForm.password || undefined,
use_tls: smtpForm.use_tls,
});
} catch (error) {
const errorMessage =
error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
alert(errorMessage || 'Failed to save SMTP settings');
}
};
const handleConnectGmail = async () => {
try {
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
const url = await gmailApi.getAuthorizeUrl(redirectUri);
window.location.href = url;
} catch (error) {
const errorMessage =
error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
alert(errorMessage || 'Failed to start Gmail authorization');
}
};
const handleDisconnectGmail = async () => {
if (confirm('Disconnect Gmail? Mail accounts using Gmail API delivery will fall back to SMTP.')) {
try {
await disconnectGmailMutation.mutateAsync();
} catch {
alert('Failed to disconnect Gmail');
}
}
};
const displayUser = currentUser ?? user;
const gmailConnected = gmailCredential?.is_valid === true;
// gmail 404 just means "not connected yet" — not a real error
const gmailNotConnected = !gmailCredential && !gmailLoading;
return (
<div className="space-y-8">
@@ -134,6 +267,218 @@ function SettingsContent() {
</form>
</div>
{/* Gmail API Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Gmail API Delivery</h2>
</div>
<div className="px-6 py-6">
<p className="text-sm text-gray-600 mb-4">
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.
</p>
<p className="text-xs text-gray-500 mb-6">
<strong>Token lifetime:</strong> Access tokens expire after 1 hour and are
refreshed automatically. Refresh tokens do not expire unless you revoke
access via your{' '}
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
Google Account permissions
</a>
. If revoked, click &ldquo;Connect Gmail&rdquo; again to re-authorise.
</p>
{gmailLoading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Checking Gmail connection
</div>
)}
{!gmailLoading && gmailConnected && (
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="text-sm font-medium text-gray-900">
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
</p>
{gmailCredential.last_verified_at && (
<p className="text-xs text-gray-500">
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleConnectGmail}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
>
Re-authorise
</button>
<button
onClick={handleDisconnectGmail}
disabled={disconnectGmailMutation.isPending}
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
>
Disconnect
</button>
</div>
</div>
)}
{!gmailLoading && gmailCredential && !gmailCredential.is_valid && (
<div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-md mb-4">
<AlertTriangle className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700">
<strong>Gmail access was revoked.</strong> Click &ldquo;Re-authorise&rdquo; below to restore Gmail API delivery.
</div>
</div>
)}
{!gmailLoading && (gmailNotConnected || (gmailCredential && !gmailCredential.is_valid)) && (
<button
onClick={handleConnectGmail}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Mail className="h-4 w-4" />
{gmailError ? 'Connect Gmail' : 'Re-authorise Gmail'}
</button>
)}
{!gmailLoading && gmailNotConnected && (
<div className="mt-3 flex items-center gap-2 text-sm text-gray-500">
<XCircle className="h-4 w-4 text-gray-400" />
No Gmail account connected yet.
</div>
)}
</div>
</div>
{/* SMTP Fallback Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Server className="h-5 w-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">SMTP Fallback</h2>
</div>
<div className="px-6 py-6">
<p className="text-sm text-gray-600 mb-6">
Used when Gmail API is not connected or when a mail account is configured
to use SMTP delivery. Your credentials are stored encrypted.
</p>
{smtpLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading SMTP settings
</div>
) : (
<form onSubmit={handleSmtpSubmit} className="space-y-4 max-w-lg">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">SMTP Host</label>
<input
type="text"
name="host"
value={smtpForm.host}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input
type="number"
name="port"
value={smtpForm.port}
onChange={handleSmtpChange}
min={1}
max={65535}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input
type="text"
name="username"
value={smtpForm.username}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
name="password"
value={smtpForm.password}
onChange={handleSmtpChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={smtpConfig?.has_password ? '•••••••• (leave blank to keep current)' : 'App password or SMTP password'}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="smtp_use_tls"
name="use_tls"
checked={smtpForm.use_tls}
onChange={handleSmtpChange}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="smtp_use_tls" className="text-sm text-gray-700">
Use STARTTLS (recommended for port 587)
</label>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={saveSmtpMutation.isPending}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saveSmtpMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</>
) : (
'Save SMTP Settings'
)}
</button>
{smtpConfig && (
<button
type="button"
onClick={() => deleteSmtpMutation.mutate()}
disabled={deleteSmtpMutation.isPending}
className="px-4 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
Remove
</button>
)}
{smtpSaved && (
<span className="flex items-center text-sm text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
Saved
</span>
)}
</div>
</form>
)}
</div>
</div>
{/* Account Information */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
@@ -204,3 +549,4 @@ function SettingsContent() {
</div>
);
}
@@ -315,6 +315,19 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
Delete after forwarding
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="is_enabled"
id="is_enabled"
checked={formData.is_enabled ?? true}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="is_enabled" className="ml-2 block text-sm text-gray-700">
Enabled
</label>
</div>
</div>
<div>
+79
View File
@@ -116,6 +116,28 @@ export interface AutoDetectSuggestion {
[key: string]: unknown;
}
export interface GmailCredential {
id: number;
user_id: number;
gmail_email: string;
is_valid: boolean;
last_verified_at?: string | null;
created_at: string;
updated_at: string;
}
export interface UserSmtpConfig {
id: number;
user_id: number;
host: string;
port: number;
username: string;
use_tls: boolean;
has_password: boolean;
created_at: string;
updated_at: string;
}
interface TokenResponse {
access_token: string;
refresh_token: string;
@@ -250,4 +272,61 @@ export const processingRunsApi = {
},
};
// ── Gmail API ───────────────────────────────────────────────────────────
export const gmailApi = {
/** Returns the Google OAuth2 URL the user should be redirected to. */
async getAuthorizeUrl(redirectUri: string): Promise<string> {
const response = await api.get<{ authorization_url: string }>(
'/providers/gmail/authorize-url',
{ params: { redirect_uri: redirectUri } }
);
return response.data.authorization_url;
},
/** Exchange an OAuth2 code for Gmail tokens and persist them. */
async saveCallback(code: string, redirectUri: string): Promise<GmailCredential> {
const response = await api.post<GmailCredential>('/providers/gmail/callback', {
code,
redirect_uri: redirectUri,
});
return response.data;
},
/** Get the current user's stored Gmail credential status. */
async getCredential(): Promise<GmailCredential> {
const response = await api.get<GmailCredential>('/providers/gmail-credential');
return response.data;
},
/** Remove stored Gmail credentials. */
async disconnect(): Promise<void> {
await api.delete('/providers/gmail-credential');
},
};
// ── SMTP Config API ─────────────────────────────────────────────────────
export const smtpApi = {
async get(): Promise<UserSmtpConfig> {
const response = await api.get<UserSmtpConfig>('/users/smtp-config');
return response.data;
},
async save(data: {
host: string;
port: number;
username: string;
password?: string;
use_tls: boolean;
}): Promise<UserSmtpConfig> {
const response = await api.put<UserSmtpConfig>('/users/smtp-config', data);
return response.data;
},
async remove(): Promise<void> {
await api.delete('/users/smtp-config');
},
};
export default api;