Files
gh-christianlouis-inboxconv…/backend/app/api/v1/endpoints/mail_accounts.py
T

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
)