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__`)
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “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 */}
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user