Add FastAPI application, API endpoints, Celery workers, and Docker configuration

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-01 12:48:32 +00:00
parent 130b53d37f
commit 5be2794c95
18 changed files with 1648 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API v1 package"""
+16
View File
@@ -0,0 +1,16 @@
"""
API v1 router aggregation.
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, mail_accounts, notifications, subscriptions, admin
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
+1
View File
@@ -0,0 +1 @@
"""API v1 endpoints package"""
+36
View File
@@ -0,0 +1,36 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.deps import get_current_superuser
from app.models.database_models import User, MailAccount, ProcessingRun
router = APIRouter()
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
):
"""Get overall system statistics (admin only)"""
# Count users
user_count = await db.execute(select(func.count(User.id)))
total_users = user_count.scalar()
# Count accounts
account_count = await db.execute(select(func.count(MailAccount.id)))
total_accounts = account_count.scalar()
# Count processing runs
run_count = await db.execute(select(func.count(ProcessingRun.id)))
total_runs = run_count.scalar()
return {
"total_users": total_users,
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs
}
+188
View File
@@ -0,0 +1,188 @@
"""
Authentication endpoints (login, register, OAuth).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
import logging
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash
from app.models.database_models import User, SubscriptionTier
from app.models.schemas import (
Token, UserCreate, UserResponse, GoogleAuthRequest
)
from app.services.auth_service import oauth_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_in: UserCreate,
db: AsyncSession = Depends(get_db)
):
"""Register a new user with email and password"""
# Check if user exists
result = await db.execute(
select(User).where(User.email == user_in.email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password) if user_in.password else None,
subscription_tier=SubscriptionTier.FREE,
is_active=True
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"New user registered: {user.email}")
return user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
"""Login with email and password"""
# Get user
result = await db.execute(
select(User).where(User.email == form_data.username)
)
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify password
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
)
# Update last login
user.last_login_at = datetime.utcnow()
await db.commit()
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
logger.info(f"User logged in: {user.email}")
return tokens
@router.post("/google", response_model=Token)
async def google_oauth(
auth_request: GoogleAuthRequest,
db: AsyncSession = Depends(get_db)
):
"""
Authenticate with Google OAuth2.
Exchange authorization code for access token and user info.
"""
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code,
redirect_uri=auth_request.redirect_uri
)
if not user_info.get('verified_email'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google"
)
email = user_info['email']
google_id = user_info['google_id']
# Check if user exists
result = await db.execute(
select(User).where(
(User.email == email) | (User.google_id == google_id)
)
)
user = result.scalar_one_or_none()
if user:
# Update Google ID if not set
if not user.google_id:
user.google_id = google_id
user.oauth_provider = "google"
# Update last login
user.last_login_at = datetime.utcnow()
logger.info(f"Existing user logged in with Google: {user.email}")
else:
# Create new user
user = User(
email=email,
full_name=user_info.get('full_name'),
google_id=google_id,
oauth_provider="google",
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.utcnow()
)
db.add(user)
logger.info(f"New user registered with Google: {user.email}")
await db.commit()
await db.refresh(user)
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
return tokens
@router.get("/google/authorize-url")
async def get_google_authorize_url(redirect_uri: str):
"""Get Google OAuth2 authorization URL"""
from app.core.config import settings
auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={settings.GOOGLE_CLIENT_ID}&"
f"response_type=code&"
f"scope=openid%20email%20profile&"
f"redirect_uri={redirect_uri}&"
f"access_type=offline"
)
return {"authorization_url": auth_url}
@@ -0,0 +1,224 @@
"""Mail account management endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
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
from app.models.schemas import (
MailAccountCreate, MailAccountResponse, MailAccountUpdate,
MailAccountTestRequest, MailAccountTestResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectResponse
)
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"""
# Check subscription limits
result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id)
)
existing_accounts = result.scalars().all()
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=f"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,
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.dict(exclude_unset=True)
if "password" in update_data:
update_data["encrypted_password"] = encrypt_credential(update_data.pop("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.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("/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
)
@@ -0,0 +1,43 @@
"""Notification configuration endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig
from app.models.schemas import (
NotificationConfigCreate, NotificationConfigResponse, NotificationConfigUpdate
)
router = APIRouter()
@router.post("", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
async def create_notification_config(
config_in: NotificationConfigCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Create notification configuration"""
config = NotificationConfig(
user_id=current_user.id,
**config_in.dict()
)
db.add(config)
await db.commit()
await db.refresh(config)
return config
@router.get("", response_model=List[NotificationConfigResponse])
async def list_notification_configs(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""List all notification configurations"""
result = await db.execute(
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
)
return result.scalars().all()
@@ -0,0 +1,35 @@
"""Subscription and payment endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, SubscriptionPlan
from app.models.schemas import SubscriptionPlanResponse
router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans(
db: AsyncSession = Depends(get_db)
):
"""List all available subscription plans"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True)
)
return result.scalars().all()
@router.get("/current")
async def get_current_subscription(
current_user: User = Depends(get_current_active_user)
):
"""Get current user's subscription details"""
return {
"tier": current_user.subscription_tier,
"status": current_user.subscription_status,
"expires_at": current_user.subscription_expires_at
}
+35
View File
@@ -0,0 +1,35 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User
from app.models.schemas import UserResponse, UserDetailResponse, UserUpdate
router = APIRouter()
@router.get("/me", response_model=UserDetailResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
):
"""Get current user profile"""
return current_user
@router.put("/me", response_model=UserDetailResponse)
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Update current user profile"""
if user_update.email:
current_user.email = user_update.email
if user_update.full_name:
current_user.full_name = user_update.full_name
await db.commit()
await db.refresh(current_user)
return current_user