Merge pull request #74 from christianlouis/copilot/add-disable-account-functionality
feat: account disable toggle, message deduplication, Gmail OAuth, per-user SMTP
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)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy import select, desc
|
|||||||
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.core.security import encrypt_credential
|
from app.core.security import encrypt_credential
|
||||||
from app.models.database_models import User, MailAccount
|
from app.models.database_models import User, MailAccount, AccountStatus
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
MailAccountCreate,
|
MailAccountCreate,
|
||||||
MailAccountResponse,
|
MailAccountResponse,
|
||||||
@@ -182,6 +182,38 @@ async def delete_mail_account(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{account_id}/toggle", response_model=MailAccountResponse)
|
||||||
|
async def toggle_mail_account(
|
||||||
|
account_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Toggle the enabled/disabled state of a mail account"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(MailAccount).where(
|
||||||
|
MailAccount.id == account_id, MailAccount.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
account.is_enabled = not account.is_enabled # type: ignore[assignment]
|
||||||
|
|
||||||
|
# When re-enabling a previously errored account, reset status to ACTIVE
|
||||||
|
# so the scheduler picks it up on the next run.
|
||||||
|
if account.is_enabled and account.status == AccountStatus.ERROR:
|
||||||
|
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(account)
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test", response_model=MailAccountTestResponse)
|
@router.post("/test", response_model=MailAccountTestResponse)
|
||||||
async def test_mail_connection(
|
async def test_mail_connection(
|
||||||
test_request: MailAccountTestRequest,
|
test_request: MailAccountTestRequest,
|
||||||
|
|||||||
@@ -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,76 @@ 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.
|
||||||
|
|
||||||
|
- For POP3: stores the UIDL string returned by the server.
|
||||||
|
- For IMAP: stores the IMAP UID (numeric string) of the message.
|
||||||
|
|
||||||
|
This prevents re-processing the same message when delete_after_forward=False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "downloaded_message_ids"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
mail_account_id = Column(
|
||||||
|
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique message identifier (UIDL for POP3, UID for IMAP)
|
||||||
|
message_uid = Column(String(512), nullable=False)
|
||||||
|
|
||||||
|
downloaded_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes — unique constraint prevents duplicates
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"idx_account_message_uid",
|
||||||
|
"mail_account_id",
|
||||||
|
"message_uid",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
Index("idx_downloaded_at", "downloaded_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GmailCredential(Base):
|
class GmailCredential(Base):
|
||||||
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
|
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from email import parser
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.utils import formatdate, make_msgid
|
from email.utils import formatdate, make_msgid
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||||
import logging
|
import logging
|
||||||
from aioimaplib import aioimaplib
|
from aioimaplib import aioimaplib
|
||||||
|
|
||||||
@@ -143,26 +143,42 @@ class MailProcessor:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"IMAP connection failed: {str(e)}"
|
return False, f"IMAP connection failed: {str(e)}"
|
||||||
|
|
||||||
async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]:
|
async def fetch_emails(
|
||||||
|
self,
|
||||||
|
max_count: Optional[int] = None,
|
||||||
|
already_seen_uids: Optional[Set[str]] = None,
|
||||||
|
) -> Tuple[List[bytes], List[str]]:
|
||||||
"""
|
"""
|
||||||
Fetch emails from the mail server.
|
Fetch emails from the mail server.
|
||||||
Returns list of raw email data.
|
|
||||||
|
Args:
|
||||||
|
max_count: Maximum number of messages to fetch.
|
||||||
|
already_seen_uids: Set of message UIDs that have already been
|
||||||
|
processed and should be skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (raw_email_bytes_list, new_uid_strings_list).
|
||||||
|
The caller should persist the new UIDs to prevent re-processing.
|
||||||
"""
|
"""
|
||||||
effective_max: int = max_count if max_count is not None else self.account.max_emails_per_check # type: ignore[assignment]
|
effective_max: int = max_count if max_count is not None else self.account.max_emails_per_check # type: ignore[assignment]
|
||||||
|
seen: Set[str] = already_seen_uids or set()
|
||||||
|
|
||||||
if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]:
|
if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]:
|
||||||
return await self._fetch_pop3_emails(effective_max)
|
return await self._fetch_pop3_emails(effective_max, seen)
|
||||||
else:
|
else:
|
||||||
return await self._fetch_imap_emails(effective_max)
|
return await self._fetch_imap_emails(effective_max, seen)
|
||||||
|
|
||||||
async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]:
|
async def _fetch_pop3_emails(
|
||||||
"""Fetch emails via POP3"""
|
self, max_count: int, already_seen_uids: Set[str]
|
||||||
emails = []
|
) -> Tuple[List[bytes], List[str]]:
|
||||||
|
"""Fetch emails via POP3, skipping already-downloaded UIDs."""
|
||||||
|
emails: List[bytes] = []
|
||||||
|
new_uids: List[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def fetch_pop3():
|
def fetch_pop3() -> Tuple[List[bytes], List[str]]:
|
||||||
# Connect
|
# Connect
|
||||||
if self.account.protocol == MailProtocol.POP3_SSL:
|
if self.account.protocol == MailProtocol.POP3_SSL:
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
@@ -181,27 +197,49 @@ class MailProcessor:
|
|||||||
pop_conn.user(self.account.username)
|
pop_conn.user(self.account.username)
|
||||||
pop_conn.pass_(self.password)
|
pop_conn.pass_(self.password)
|
||||||
|
|
||||||
# Get message count
|
# Retrieve UIDL map: {msg_number: uid_string}
|
||||||
num_messages = len(pop_conn.list()[1])
|
uidl_response = pop_conn.uidl()
|
||||||
|
uid_map: Dict[int, str] = {}
|
||||||
|
for entry in uidl_response[1]:
|
||||||
|
parts = entry.decode().split(" ", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
uid_map[int(parts[0])] = parts[1].strip()
|
||||||
|
|
||||||
|
num_messages = len(uid_map)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Found {num_messages} messages for account {self.account.id}"
|
f"Found {num_messages} messages for account {self.account.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
fetched_emails = []
|
fetched: List[bytes] = []
|
||||||
messages_to_delete = []
|
fetched_uids: List[str] = []
|
||||||
|
messages_to_delete: List[int] = []
|
||||||
|
fetched_count = 0
|
||||||
|
|
||||||
|
for msg_num, uid in uid_map.items():
|
||||||
|
if fetched_count >= max_count:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip messages we already processed
|
||||||
|
if uid in already_seen_uids:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping already-downloaded message {uid} "
|
||||||
|
f"for account {self.account.id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Fetch emails (limited by max_count)
|
|
||||||
for i in range(1, min(num_messages + 1, max_count + 1)):
|
|
||||||
try:
|
try:
|
||||||
response, lines, octets = pop_conn.retr(i)
|
response, lines, octets = pop_conn.retr(msg_num)
|
||||||
email_data = b"\r\n".join(lines)
|
email_data = b"\r\n".join(lines)
|
||||||
fetched_emails.append(email_data)
|
fetched.append(email_data)
|
||||||
messages_to_delete.append(i)
|
fetched_uids.append(uid)
|
||||||
|
messages_to_delete.append(msg_num)
|
||||||
|
fetched_count += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Retrieved message {i} from account {self.account.id}"
|
f"Retrieved message {msg_num} (uid={uid}) "
|
||||||
|
f"from account {self.account.id}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving message {i}: {e}")
|
logger.error(f"Error retrieving message {msg_num}: {e}")
|
||||||
|
|
||||||
# Delete messages if configured
|
# Delete messages if configured
|
||||||
if self.account.delete_after_forward:
|
if self.account.delete_after_forward:
|
||||||
@@ -212,19 +250,22 @@ class MailProcessor:
|
|||||||
logger.error(f"Error deleting message {msg_id}: {e}")
|
logger.error(f"Error deleting message {msg_id}: {e}")
|
||||||
|
|
||||||
pop_conn.quit()
|
pop_conn.quit()
|
||||||
return fetched_emails
|
return fetched, fetched_uids
|
||||||
|
|
||||||
emails = await loop.run_in_executor(None, fetch_pop3)
|
emails, new_uids = await loop.run_in_executor(None, fetch_pop3)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching POP3 emails: {e}")
|
logger.error(f"Error fetching POP3 emails: {e}")
|
||||||
raise MailFetchError(f"POP3 fetch error: {str(e)}")
|
raise MailFetchError(f"POP3 fetch error: {str(e)}")
|
||||||
|
|
||||||
return emails
|
return emails, new_uids
|
||||||
|
|
||||||
async def _fetch_imap_emails(self, max_count: int) -> List[bytes]:
|
async def _fetch_imap_emails(
|
||||||
"""Fetch emails via IMAP"""
|
self, max_count: int, already_seen_uids: Set[str]
|
||||||
emails = []
|
) -> Tuple[List[bytes], List[str]]:
|
||||||
|
"""Fetch emails via IMAP, marking each message \Seen to prevent re-fetch."""
|
||||||
|
emails: List[bytes] = []
|
||||||
|
new_uids: List[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create IMAP client
|
# Create IMAP client
|
||||||
@@ -241,8 +282,8 @@ class MailProcessor:
|
|||||||
await imap_client.login(self.account.username, self.password)
|
await imap_client.login(self.account.username, self.password)
|
||||||
await imap_client.select("INBOX")
|
await imap_client.select("INBOX")
|
||||||
|
|
||||||
# Search for all messages
|
# Search for unseen messages only
|
||||||
response = await imap_client.search("UNSEEN") # Only fetch unread
|
response = await imap_client.search("UNSEEN")
|
||||||
message_ids = response.lines[0].split()
|
message_ids = response.lines[0].split()
|
||||||
|
|
||||||
# Limit to max_count
|
# Limit to max_count
|
||||||
@@ -254,6 +295,18 @@ class MailProcessor:
|
|||||||
|
|
||||||
# Fetch each message
|
# Fetch each message
|
||||||
for msg_id in message_ids:
|
for msg_id in message_ids:
|
||||||
|
uid_str = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)
|
||||||
|
|
||||||
|
# Skip messages already tracked in our DB
|
||||||
|
if uid_str in already_seen_uids:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping already-processed IMAP message {uid_str} "
|
||||||
|
f"for account {self.account.id}"
|
||||||
|
)
|
||||||
|
# Still mark as Seen so it doesn't show up in UNSEEN searches
|
||||||
|
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await imap_client.fetch(msg_id, "(RFC822)")
|
response = await imap_client.fetch(msg_id, "(RFC822)")
|
||||||
|
|
||||||
@@ -272,8 +325,12 @@ class MailProcessor:
|
|||||||
|
|
||||||
if email_data:
|
if email_data:
|
||||||
emails.append(email_data)
|
emails.append(email_data)
|
||||||
|
new_uids.append(uid_str)
|
||||||
|
|
||||||
|
# Always mark as Seen after fetching so the message is
|
||||||
|
# not picked up again on the next UNSEEN search.
|
||||||
|
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
|
||||||
|
|
||||||
# Mark as seen if deleting after forward
|
|
||||||
if self.account.delete_after_forward:
|
if self.account.delete_after_forward:
|
||||||
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
|
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
|
||||||
|
|
||||||
@@ -290,7 +347,7 @@ class MailProcessor:
|
|||||||
logger.error(f"Error fetching IMAP emails: {e}")
|
logger.error(f"Error fetching IMAP emails: {e}")
|
||||||
raise MailFetchError(f"IMAP fetch error: {str(e)}")
|
raise MailFetchError(f"IMAP fetch error: {str(e)}")
|
||||||
|
|
||||||
return emails
|
return emails, new_uids
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def forward_email(
|
async def forward_email(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -17,12 +17,14 @@ from app.models.database_models import (
|
|||||||
AccountStatus,
|
AccountStatus,
|
||||||
DeliveryMethod,
|
DeliveryMethod,
|
||||||
GmailCredential,
|
GmailCredential,
|
||||||
|
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_
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,11 +71,22 @@ async def process_mail_account(account_id: int):
|
|||||||
# Decrypt password
|
# Decrypt password
|
||||||
password = decrypt_credential(account.encrypted_password) # type: ignore[arg-type]
|
password = decrypt_credential(account.encrypted_password) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Load already-downloaded UIDs to prevent re-processing
|
||||||
|
seen_result = await db.execute(
|
||||||
|
select(DownloadedMessageId.message_uid).where(
|
||||||
|
DownloadedMessageId.mail_account_id == account.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
already_seen_uids = set(seen_result.scalars().all())
|
||||||
|
|
||||||
# Create processor
|
# Create processor
|
||||||
processor = MailProcessor(account, password)
|
processor = MailProcessor(account, password)
|
||||||
|
|
||||||
# Fetch emails
|
# Fetch emails (returns raw bytes + new UIDs)
|
||||||
emails = await processor.fetch_emails(account.max_emails_per_check) # type: ignore[arg-type]
|
emails, new_uids = await processor.fetch_emails(
|
||||||
|
account.max_emails_per_check, # type: ignore[arg-type]
|
||||||
|
already_seen_uids=already_seen_uids,
|
||||||
|
)
|
||||||
|
|
||||||
run.emails_fetched = len(emails) # type: ignore[assignment]
|
run.emails_fetched = len(emails) # type: ignore[assignment]
|
||||||
|
|
||||||
@@ -118,7 +131,23 @@ 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
|
||||||
|
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)
|
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"]:
|
||||||
@@ -130,7 +159,15 @@ async def process_mail_account(account_id: int):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
for email_data in emails:
|
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:
|
try:
|
||||||
if use_gmail_api and gmail_service:
|
if use_gmail_api and gmail_service:
|
||||||
# Inject via Gmail API (preferred)
|
# Inject via Gmail API (preferred)
|
||||||
@@ -140,6 +177,7 @@ async def process_mail_account(account_id: int):
|
|||||||
source_account_name=account.name, # type: ignore[arg-type]
|
source_account_name=account.name, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
emails_forwarded += 1
|
emails_forwarded += 1
|
||||||
|
successfully_forwarded_uids.append(uid)
|
||||||
else:
|
else:
|
||||||
# Forward via SMTP (fallback)
|
# Forward via SMTP (fallback)
|
||||||
success = await MailProcessor.forward_email(
|
success = await MailProcessor.forward_email(
|
||||||
@@ -147,13 +185,54 @@ async def process_mail_account(account_id: int):
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
emails_forwarded += 1
|
emails_forwarded += 1
|
||||||
|
successfully_forwarded_uids.append(uid)
|
||||||
else:
|
else:
|
||||||
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
|
||||||
|
|
||||||
|
# Persist new message UIDs so they are not processed again
|
||||||
|
for uid in successfully_forwarded_uids:
|
||||||
|
if uid not in already_seen_uids:
|
||||||
|
db.add(
|
||||||
|
DownloadedMessageId(
|
||||||
|
mail_account_id=account.id,
|
||||||
|
message_uid=uid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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]
|
||||||
@@ -210,15 +289,11 @@ async def process_all_enabled_accounts():
|
|||||||
"""
|
"""
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
try:
|
||||||
# Get all enabled accounts
|
# Fetch all enabled accounts regardless of operational status so
|
||||||
|
# that accounts in ERROR state are retried automatically.
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(MailAccount).where(
|
select(MailAccount).where(
|
||||||
and_(
|
|
||||||
MailAccount.is_enabled == True, # noqa: E712
|
MailAccount.is_enabled == True, # noqa: E712
|
||||||
MailAccount.status.in_(
|
|
||||||
[AccountStatus.ACTIVE, AccountStatus.TESTING]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
accounts = result.scalars().all()
|
accounts = result.scalars().all()
|
||||||
@@ -248,10 +323,10 @@ async def process_all_enabled_accounts():
|
|||||||
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
|
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
|
||||||
async def cleanup_old_logs(days_to_keep: int = 30):
|
async def cleanup_old_logs(days_to_keep: int = 30):
|
||||||
"""
|
"""
|
||||||
Clean up old processing logs and runs.
|
Clean up old processing logs, runs, and downloaded message ID records.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
days_to_keep: Number of days of logs to retain
|
days_to_keep: Number of days of data to retain
|
||||||
"""
|
"""
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
try:
|
||||||
@@ -275,6 +350,14 @@ async def cleanup_old_logs(days_to_keep: int = 30):
|
|||||||
for log in old_logs:
|
for log in old_logs:
|
||||||
await db.delete(log)
|
await db.delete(log)
|
||||||
|
|
||||||
|
# Delete old downloaded message ID records so the table doesn't
|
||||||
|
# grow unboundedly for accounts that never delete messages.
|
||||||
|
await db.execute(
|
||||||
|
delete(DownloadedMessageId).where(
|
||||||
|
DownloadedMessageId.downloaded_at < cutoff_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
|||||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||||
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
|
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
|
||||||
|
|
||||||
@@ -25,6 +25,13 @@ export default function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: mailAccountsApi.toggle,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleEdit = (account: MailAccount) => {
|
const handleEdit = (account: MailAccount) => {
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -40,6 +47,14 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await toggleMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
alert('Failed to update account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
@@ -69,7 +84,9 @@ export default function AccountsPage() {
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden"
|
className={`bg-white rounded-lg shadow-md border overflow-hidden transition-opacity ${
|
||||||
|
account.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
@@ -81,9 +98,15 @@ export default function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{account.is_enabled ? (
|
{account.is_enabled ? (
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" aria-label="Enabled" />
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-5 w-5 text-gray-400" aria-label="Disabled" />
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +152,18 @@ export default function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
|
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(account.id)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
title={account.is_enabled ? 'Disable account' : 'Enable account'}
|
||||||
|
className={`flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
|
||||||
|
account.is_enabled
|
||||||
|
? 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100'
|
||||||
|
: 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(account)}
|
onClick={() => handleEdit(account)}
|
||||||
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -204,6 +226,11 @@ export const mailAccountsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async toggle(id: number): Promise<MailAccount> {
|
||||||
|
const response = await api.patch<MailAccount>(`/mail-accounts/${id}/toggle`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
async delete(id: number): Promise<void> {
|
||||||
await api.delete(`/mail-accounts/${id}`);
|
await api.delete(`/mail-accounts/${id}`);
|
||||||
},
|
},
|
||||||
@@ -245,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