441e8b54e9
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6268035d-325f-422a-886f-8b2919127261 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
462 lines
15 KiB
Python
462 lines
15 KiB
Python
"""Mail account management endpoints"""
|
|
|
|
import math
|
|
from typing import List, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc, func
|
|
|
|
from app.core.database import get_db
|
|
from app.core.deps import get_current_active_user
|
|
from app.core.security import encrypt_credential, decrypt_credential
|
|
from app.models.database_models import (
|
|
User,
|
|
MailAccount,
|
|
ProcessingLog,
|
|
ProcessingRun,
|
|
AccountStatus,
|
|
SubscriptionPlan,
|
|
)
|
|
from app.models.schemas import (
|
|
MailAccountCreate,
|
|
MailAccountResponse,
|
|
MailAccountUpdate,
|
|
MailAccountTestRequest,
|
|
MailAccountTestResponse,
|
|
MailAccountAutoDetectRequest,
|
|
MailAccountAutoDetectResponse,
|
|
PaginatedProcessingRunsResponse,
|
|
PaginatedProcessingLogsResponse,
|
|
ProcessingRunDetailResponse,
|
|
ProcessingLogDetailResponse,
|
|
)
|
|
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
|
|
from app.core.config import settings
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post(
|
|
"", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def create_mail_account(
|
|
account_in: MailAccountCreate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new mail account"""
|
|
|
|
# Superusers are not subject to subscription limits
|
|
if not current_user.is_superuser:
|
|
# Count existing accounts for this user
|
|
result = await db.execute(
|
|
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
|
)
|
|
existing_accounts = result.scalars().all()
|
|
|
|
# Try to look up the limit from the active SubscriptionPlan in the DB first
|
|
# so that admin-managed plan limits take effect immediately.
|
|
plan_result = await db.execute(
|
|
select(SubscriptionPlan).where(
|
|
SubscriptionPlan.tier == current_user.subscription_tier,
|
|
SubscriptionPlan.is_active.is_(True),
|
|
)
|
|
)
|
|
plan = plan_result.scalar_one_or_none()
|
|
|
|
if plan is not None:
|
|
max_accounts = plan.max_mail_accounts
|
|
else:
|
|
# Fall back to env-var / config values when no plan row exists
|
|
tier_limits = {
|
|
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
|
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
|
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
|
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
|
}
|
|
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
|
|
|
if len(existing_accounts) >= max_accounts:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail="Account limit reached. Upgrade your subscription to add more accounts.",
|
|
)
|
|
|
|
# Encrypt password
|
|
encrypted_password = encrypt_credential(account_in.password)
|
|
|
|
# Create account
|
|
account = MailAccount(
|
|
user_id=current_user.id,
|
|
name=account_in.name,
|
|
email_address=account_in.email_address,
|
|
protocol=account_in.protocol,
|
|
host=account_in.host,
|
|
port=account_in.port,
|
|
use_ssl=account_in.use_ssl,
|
|
use_tls=account_in.use_tls,
|
|
username=account_in.username,
|
|
encrypted_password=encrypted_password,
|
|
forward_to=account_in.forward_to,
|
|
delivery_method=account_in.delivery_method,
|
|
is_enabled=account_in.is_enabled,
|
|
check_interval_minutes=account_in.check_interval_minutes,
|
|
max_emails_per_check=account_in.max_emails_per_check,
|
|
delete_after_forward=account_in.delete_after_forward,
|
|
)
|
|
|
|
db.add(account)
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return account
|
|
|
|
|
|
@router.get("", response_model=List[MailAccountResponse])
|
|
async def list_mail_accounts(
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all mail accounts for current user"""
|
|
result = await db.execute(
|
|
select(MailAccount)
|
|
.where(MailAccount.user_id == current_user.id)
|
|
.order_by(desc(MailAccount.created_at))
|
|
)
|
|
accounts = result.scalars().all()
|
|
return accounts
|
|
|
|
|
|
@router.get("/{account_id}", response_model=MailAccountResponse)
|
|
async def get_mail_account(
|
|
account_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a specific 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"
|
|
)
|
|
|
|
return account
|
|
|
|
|
|
@router.put("/{account_id}", response_model=MailAccountResponse)
|
|
async def update_mail_account(
|
|
account_id: int,
|
|
account_update: MailAccountUpdate,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update 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"
|
|
)
|
|
|
|
# Update fields
|
|
update_data = account_update.model_dump(exclude_unset=True)
|
|
|
|
if "password" in update_data:
|
|
password = update_data.pop("password")
|
|
if password: # Only update when a non-empty password is provided
|
|
update_data["encrypted_password"] = encrypt_credential(password)
|
|
|
|
for field, value in update_data.items():
|
|
setattr(account, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return account
|
|
|
|
|
|
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_mail_account(
|
|
account_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete 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"
|
|
)
|
|
|
|
await db.delete(account)
|
|
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)
|
|
async def test_mail_connection(
|
|
test_request: MailAccountTestRequest,
|
|
current_user: User = Depends(get_current_active_user),
|
|
):
|
|
"""Test connection to mail server"""
|
|
|
|
# Create temporary account for testing
|
|
temp_account = MailAccount(
|
|
user_id=current_user.id,
|
|
name="test",
|
|
email_address="test@test.com",
|
|
protocol=test_request.protocol,
|
|
host=test_request.host,
|
|
port=test_request.port,
|
|
use_ssl=test_request.use_ssl,
|
|
use_tls=test_request.use_tls,
|
|
username=test_request.username,
|
|
encrypted_password="", # Not used for test
|
|
forward_to="test@test.com",
|
|
)
|
|
|
|
processor = MailProcessor(temp_account, test_request.password)
|
|
success, message = await processor.test_connection()
|
|
|
|
return MailAccountTestResponse(success=success, message=message)
|
|
|
|
|
|
@router.post("/{account_id}/test", response_model=MailAccountTestResponse)
|
|
async def test_existing_mail_connection(
|
|
account_id: int,
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Test connection for an existing mail account using its stored credentials"""
|
|
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"
|
|
)
|
|
|
|
try:
|
|
password = decrypt_credential(account.encrypted_password)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to decrypt stored credentials",
|
|
)
|
|
|
|
processor = MailProcessor(account, password)
|
|
success, message = await processor.test_connection()
|
|
|
|
return MailAccountTestResponse(success=success, message=message)
|
|
|
|
|
|
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
|
|
async def auto_detect_mail_settings(
|
|
detect_request: MailAccountAutoDetectRequest,
|
|
current_user: User = Depends(get_current_active_user),
|
|
):
|
|
"""Auto-detect mail server settings for an email address"""
|
|
|
|
suggestions = MailServerAutoDetect.detect(detect_request.email_address)
|
|
|
|
return MailAccountAutoDetectResponse(
|
|
success=len(suggestions) > 0, suggestions=suggestions
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-account processing runs & logs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/{account_id}/processing-runs",
|
|
response_model=PaginatedProcessingRunsResponse,
|
|
summary="List processing runs for a specific mail account",
|
|
)
|
|
async def list_account_runs(
|
|
account_id: int,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return paginated processing runs for a mail account owned by the user."""
|
|
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"
|
|
)
|
|
|
|
base = select(ProcessingRun).where(ProcessingRun.mail_account_id == account_id)
|
|
total = (
|
|
await db.execute(select(func.count()).select_from(base.subquery()))
|
|
).scalar_one()
|
|
|
|
offset = (page - 1) * page_size
|
|
runs = (
|
|
(
|
|
await db.execute(
|
|
base.order_by(desc(ProcessingRun.started_at))
|
|
.offset(offset)
|
|
.limit(page_size)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
pages = max(1, math.ceil(total / page_size)) if total else 1
|
|
items = [
|
|
ProcessingRunDetailResponse(
|
|
id=r.id, # type: ignore[arg-type]
|
|
mail_account_id=r.mail_account_id, # type: ignore[arg-type]
|
|
started_at=r.started_at, # type: ignore[arg-type]
|
|
completed_at=r.completed_at, # type: ignore[arg-type]
|
|
duration_seconds=r.duration_seconds, # type: ignore[arg-type]
|
|
emails_fetched=r.emails_fetched, # type: ignore[arg-type]
|
|
emails_forwarded=r.emails_forwarded, # type: ignore[arg-type]
|
|
emails_failed=r.emails_failed, # type: ignore[arg-type]
|
|
status=r.status, # type: ignore[arg-type]
|
|
error_message=r.error_message, # type: ignore[arg-type]
|
|
account_name=account.name, # type: ignore[arg-type]
|
|
account_email=account.email_address, # type: ignore[arg-type]
|
|
)
|
|
for r in runs
|
|
]
|
|
return PaginatedProcessingRunsResponse(
|
|
items=items, total=total, page=page, page_size=page_size, pages=pages
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{account_id}/logs",
|
|
response_model=PaginatedProcessingLogsResponse,
|
|
summary="List processing logs for a specific mail account",
|
|
)
|
|
async def list_account_logs(
|
|
account_id: int,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
level: Optional[str] = Query(
|
|
None, description="Filter by log level (INFO, WARNING, ERROR)"
|
|
),
|
|
current_user: User = Depends(get_current_active_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Return paginated per-email log entries for a mail account owned by the user."""
|
|
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"
|
|
)
|
|
|
|
base = select(ProcessingLog).where(
|
|
ProcessingLog.mail_account_id == account_id,
|
|
ProcessingLog.user_id == current_user.id, # type: ignore[arg-type]
|
|
)
|
|
if level:
|
|
base = base.where(ProcessingLog.level == level.upper())
|
|
|
|
total = (
|
|
await db.execute(select(func.count()).select_from(base.subquery()))
|
|
).scalar_one()
|
|
|
|
offset = (page - 1) * page_size
|
|
logs = (
|
|
(
|
|
await db.execute(
|
|
base.order_by(ProcessingLog.timestamp.desc())
|
|
.offset(offset)
|
|
.limit(page_size)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
pages = max(1, math.ceil(total / page_size)) if total else 1
|
|
items = [
|
|
ProcessingLogDetailResponse(
|
|
id=log.id, # type: ignore[arg-type]
|
|
timestamp=log.timestamp, # type: ignore[arg-type]
|
|
level=log.level, # type: ignore[arg-type]
|
|
message=log.message, # type: ignore[arg-type]
|
|
email_subject=log.email_subject, # type: ignore[arg-type]
|
|
email_from=log.email_from, # type: ignore[arg-type]
|
|
success=log.success, # type: ignore[arg-type]
|
|
mail_account_id=log.mail_account_id, # type: ignore[arg-type]
|
|
processing_run_id=log.processing_run_id, # type: ignore[arg-type]
|
|
email_size_bytes=log.email_size_bytes, # type: ignore[arg-type]
|
|
error_details=log.error_details, # type: ignore[arg-type]
|
|
)
|
|
for log in logs
|
|
]
|
|
return PaginatedProcessingLogsResponse(
|
|
items=items, total=total, page=page, page_size=page_size, pages=pages
|
|
)
|