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:
+11
-1
@@ -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__`)
|
- 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
|
### 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.
|
- **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
|
- **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)
|
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Provider presets and Gmail credential management endpoints"""
|
"""Provider presets and Gmail credential management endpoints"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
@@ -16,10 +18,21 @@ from app.models.schemas import (
|
|||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
GmailCredentialCreate,
|
GmailCredentialCreate,
|
||||||
GmailCredentialResponse,
|
GmailCredentialResponse,
|
||||||
|
GmailAuthorizeResponse,
|
||||||
|
GmailCallbackRequest,
|
||||||
)
|
)
|
||||||
from app.services.gmail_service import GmailService
|
from app.services.gmail_service import GmailService
|
||||||
|
|
||||||
router = APIRouter()
|
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 with server configurations
|
||||||
PROVIDER_PRESETS: List[ProviderPreset] = [
|
PROVIDER_PRESETS: List[ProviderPreset] = [
|
||||||
@@ -268,3 +281,166 @@ async def delete_gmail_credential(
|
|||||||
|
|
||||||
await db.delete(credential)
|
await db.delete(credential)
|
||||||
await db.commit()
|
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
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""User management endpoints"""
|
"""User management endpoints"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
from app.models.database_models import User
|
from app.core.security import encrypt_credential
|
||||||
from app.models.schemas import UserDetailResponse, UserUpdate
|
from app.models.database_models import User, UserSmtpConfig
|
||||||
|
from app.models.schemas import (
|
||||||
|
UserDetailResponse,
|
||||||
|
UserUpdate,
|
||||||
|
UserSmtpConfigUpdate,
|
||||||
|
UserSmtpConfigResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -34,3 +41,96 @@ async def update_current_user_profile(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
return 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()
|
||||||
|
|||||||
@@ -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):
|
class DownloadedMessageId(Base):
|
||||||
"""
|
"""
|
||||||
Tracks unique message IDs that have already been downloaded and forwarded.
|
Tracks unique message IDs that have already been downloaded and forwarded.
|
||||||
|
|||||||
@@ -339,3 +339,35 @@ class ProviderPreset(BaseModel):
|
|||||||
|
|
||||||
class ProviderListResponse(BaseModel):
|
class ProviderListResponse(BaseModel):
|
||||||
providers: List[ProviderPreset]
|
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
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class GmailService:
|
|||||||
client_id: Google OAuth2 client ID
|
client_id: Google OAuth2 client ID
|
||||||
client_secret: Google OAuth2 client secret
|
client_secret: Google OAuth2 client secret
|
||||||
"""
|
"""
|
||||||
|
self._initial_access_token = access_token
|
||||||
self.credentials = Credentials(
|
self.credentials = Credentials(
|
||||||
token=access_token,
|
token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -135,6 +136,7 @@ class GmailService:
|
|||||||
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
# Surface 401 so callers can mark credentials as invalid
|
||||||
raise GmailInjectionError(error_msg)
|
raise GmailInjectionError(error_msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to inject email into Gmail: {str(e)}"
|
error_msg = f"Failed to inject email into Gmail: {str(e)}"
|
||||||
@@ -180,3 +182,24 @@ class GmailService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get Gmail email address: {e}")
|
logger.error(f"Failed to get Gmail email address: {e}")
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import logging
|
|||||||
|
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
from app.core.database import async_session_maker
|
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 (
|
from app.models.database_models import (
|
||||||
MailAccount,
|
MailAccount,
|
||||||
ProcessingRun,
|
ProcessingRun,
|
||||||
@@ -18,12 +18,13 @@ from app.models.database_models import (
|
|||||||
DeliveryMethod,
|
DeliveryMethod,
|
||||||
GmailCredential,
|
GmailCredential,
|
||||||
DownloadedMessageId,
|
DownloadedMessageId,
|
||||||
|
UserSmtpConfig,
|
||||||
)
|
)
|
||||||
from app.services.mail_processor import MailProcessor
|
from app.services.mail_processor import MailProcessor
|
||||||
from app.services.gmail_service import GmailService
|
from app.services.gmail_service import GmailService
|
||||||
from app.services.config_service import ConfigService
|
from app.services.config_service import ConfigService
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from sqlalchemy import select, and_, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -130,8 +131,24 @@ async def process_mail_account(account_id: int):
|
|||||||
use_gmail_api = False # type: ignore[assignment]
|
use_gmail_api = False # type: ignore[assignment]
|
||||||
|
|
||||||
if not use_gmail_api:
|
if not use_gmail_api:
|
||||||
# Fall back to SMTP – read config from DB with env fallback
|
# Fall back to SMTP – check per-user config first, then global
|
||||||
smtp_config = await ConfigService.get_smtp_config(db=db)
|
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"]:
|
if not smtp_config["username"] or not smtp_config["password"]:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -144,6 +161,12 @@ async def process_mail_account(account_id: int):
|
|||||||
|
|
||||||
successfully_forwarded_uids: list[str] = []
|
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):
|
for email_data, uid in zip(emails, new_uids):
|
||||||
try:
|
try:
|
||||||
if use_gmail_api and gmail_service:
|
if use_gmail_api and gmail_service:
|
||||||
@@ -167,6 +190,23 @@ async def process_mail_account(account_id: int):
|
|||||||
emails_failed += 1
|
emails_failed += 1
|
||||||
|
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"Error delivering email: {e}")
|
||||||
emails_failed += 1
|
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
|
# Update run
|
||||||
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
|
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
|
||||||
run.emails_failed = emails_failed # type: ignore[assignment]
|
run.emails_failed = emails_failed # type: ignore[assignment]
|
||||||
|
|||||||
+8
-3
@@ -179,6 +179,10 @@ Comprehensive task breakdown for repository improvements and production readines
|
|||||||
### Not Started 📋
|
### Not Started 📋
|
||||||
- [ ] Implement Stripe webhook handling
|
- [ ] Implement Stripe webhook handling
|
||||||
- [ ] Add scheduled Celery tasks for email processing
|
- [ ] 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
|
- [ ] Implement GDPR data export endpoint
|
||||||
- [ ] Complete notification service integration (Apprise)
|
- [ ] Complete notification service integration (Apprise)
|
||||||
- [ ] Add advanced email filtering
|
- [ ] Add advanced email filtering
|
||||||
@@ -207,12 +211,13 @@ because the API client layer is missing.
|
|||||||
- [x] Registration page
|
- [x] Registration page
|
||||||
- [x] OAuth callback handler
|
- [x] OAuth callback handler
|
||||||
- [x] Dashboard with stats cards and processing runs table
|
- [x] Dashboard with stats cards and processing runs table
|
||||||
- [x] Mail accounts list with CRUD operations
|
- [x] Mail accounts list with CRUD operations + enable/disable toggle
|
||||||
- [x] Settings page
|
- [x] Settings page — Profile, Gmail API connection, SMTP relay, Account info, Security
|
||||||
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields)
|
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields, is_enabled checkbox)
|
||||||
- [x] `DashboardLayout` with responsive sidebar
|
- [x] `DashboardLayout` with responsive sidebar
|
||||||
- [x] `AuthGuard` for protected routes
|
- [x] `AuthGuard` for protected routes
|
||||||
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity` → `/75` syntax, modal restructure)
|
- [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 📋
|
### Not Started 📋
|
||||||
- [ ] End-to-end testing of frontend against backend API
|
- [ ] 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,18 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { AuthGuard } from '@/components/AuthGuard';
|
import { AuthGuard } from '@/components/AuthGuard';
|
||||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||||
import { userApi } from '@/lib/api';
|
import { userApi, gmailApi, smtpApi } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
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() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +37,16 @@ function SettingsContent() {
|
|||||||
});
|
});
|
||||||
const [profileSaved, setProfileSaved] = useState(false);
|
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
|
// Refresh user data from the server
|
||||||
const { data: currentUser } = useQuery({
|
const { data: currentUser } = useQuery({
|
||||||
queryKey: ['current-user'],
|
queryKey: ['current-user'],
|
||||||
@@ -35,6 +54,38 @@ function SettingsContent() {
|
|||||||
initialData: user ?? undefined,
|
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({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: (data: { full_name: string; email: string }) =>
|
mutationFn: (data: { full_name: string; email: string }) =>
|
||||||
userApi.updateProfile(data),
|
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 handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setProfileForm((prev) => ({ ...prev, [name]: value }));
|
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) => {
|
const handleProfileSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -134,6 +267,218 @@ function SettingsContent() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 “Connect Gmail” 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 “Re-authorise” 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 */}
|
{/* Account Information */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
|
||||||
@@ -204,3 +549,4 @@ function SettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,6 +315,19 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
|||||||
Delete after forwarding
|
Delete after forwarding
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -116,6 +116,28 @@ export interface AutoDetectSuggestion {
|
|||||||
[key: string]: unknown;
|
[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 {
|
interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_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;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user