Fix CI failures: add backend/conftest.py for module resolution and run black formatting
- Add backend/conftest.py that inserts the backend directory into sys.path, fixing ModuleNotFoundError when pytest runs from the backend/ directory (as CI does with `cd backend && pytest tests/`) - Run black formatter on all 28 backend files that needed reformatting - All 53 tests pass with both `pytest tests/` and `python -m pytest tests/` Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/beb47db2-da25-416a-8fb0-c1452a3b22a7
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Alembic environment configuration"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
@@ -49,10 +50,7 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
"""
|
||||
API v1 router aggregation.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, users, mail_accounts, notifications, subscriptions, admin, providers
|
||||
from app.api.v1.endpoints import (
|
||||
auth,
|
||||
users,
|
||||
mail_accounts,
|
||||
notifications,
|
||||
subscriptions,
|
||||
admin,
|
||||
providers,
|
||||
)
|
||||
|
||||
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(providers.router, prefix="/providers", tags=["Providers & Gmail"])
|
||||
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
|
||||
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||
api_router.include_router(
|
||||
mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"]
|
||||
)
|
||||
api_router.include_router(
|
||||
providers.router, prefix="/providers", tags=["Providers & Gmail"]
|
||||
)
|
||||
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,4 +1,5 @@
|
||||
"""Admin endpoints"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
@@ -13,7 +14,7 @@ router = APIRouter()
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get overall system statistics (admin only)"""
|
||||
|
||||
@@ -32,5 +33,5 @@ async def get_admin_stats(
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"total_mail_accounts": total_accounts,
|
||||
"total_processing_runs": total_runs
|
||||
"total_processing_runs": total_runs,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Authentication endpoints (login, register, OAuth).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,41 +12,37 @@ 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.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)
|
||||
):
|
||||
@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)
|
||||
)
|
||||
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"
|
||||
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,
|
||||
hashed_password=(
|
||||
get_password_hash(user_in.password) if user_in.password else None
|
||||
),
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
is_active=True
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
@@ -59,15 +56,12 @@ async def register(
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
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)
|
||||
)
|
||||
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:
|
||||
@@ -88,8 +82,7 @@ async def login(
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||
)
|
||||
|
||||
# Update last login
|
||||
@@ -106,8 +99,7 @@ async def login(
|
||||
|
||||
@router.post("/google", response_model=Token)
|
||||
async def google_oauth(
|
||||
auth_request: GoogleAuthRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
auth_request: GoogleAuthRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Authenticate with Google OAuth2.
|
||||
@@ -116,24 +108,21 @@ async def google_oauth(
|
||||
|
||||
# Get user info from Google
|
||||
user_info = await oauth_service.get_google_user_info(
|
||||
code=auth_request.code,
|
||||
redirect_uri=auth_request.redirect_uri
|
||||
code=auth_request.code, redirect_uri=auth_request.redirect_uri
|
||||
)
|
||||
|
||||
if not user_info.get('verified_email'):
|
||||
if not user_info.get("verified_email"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email not verified with Google"
|
||||
detail="Email not verified with Google",
|
||||
)
|
||||
|
||||
email = user_info['email']
|
||||
google_id = user_info['google_id']
|
||||
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)
|
||||
)
|
||||
select(User).where((User.email == email) | (User.google_id == google_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -151,12 +140,12 @@ async def google_oauth(
|
||||
# Create new user
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=user_info.get('full_name'),
|
||||
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()
|
||||
last_login_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Mail account management endpoints"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -9,9 +10,13 @@ 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
|
||||
MailAccountCreate,
|
||||
MailAccountResponse,
|
||||
MailAccountUpdate,
|
||||
MailAccountTestRequest,
|
||||
MailAccountTestResponse,
|
||||
MailAccountAutoDetectRequest,
|
||||
MailAccountAutoDetectResponse,
|
||||
)
|
||||
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
|
||||
from app.core.config import settings
|
||||
@@ -19,11 +24,13 @@ from app.core.config import settings
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED)
|
||||
@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)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new mail account"""
|
||||
|
||||
@@ -45,7 +52,7 @@ async def create_mail_account(
|
||||
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."
|
||||
detail=f"Account limit reached. Upgrade your subscription to add more accounts.",
|
||||
)
|
||||
|
||||
# Encrypt password
|
||||
@@ -68,7 +75,7 @@ async def create_mail_account(
|
||||
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
|
||||
delete_after_forward=account_in.delete_after_forward,
|
||||
)
|
||||
|
||||
db.add(account)
|
||||
@@ -81,7 +88,7 @@ async def create_mail_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)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all mail accounts for current user"""
|
||||
result = await db.execute(
|
||||
@@ -97,21 +104,19 @@ async def list_mail_accounts(
|
||||
async def get_mail_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
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
|
||||
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"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
|
||||
)
|
||||
|
||||
return account
|
||||
@@ -122,28 +127,28 @@ async def update_mail_account(
|
||||
account_id: int,
|
||||
account_update: MailAccountUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
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
|
||||
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"
|
||||
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"))
|
||||
update_data["encrypted_password"] = encrypt_credential(
|
||||
update_data.pop("password")
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(account, field, value)
|
||||
@@ -158,21 +163,19 @@ async def update_mail_account(
|
||||
async def delete_mail_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
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
|
||||
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"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
|
||||
)
|
||||
|
||||
await db.delete(account)
|
||||
@@ -198,16 +201,13 @@ async def test_mail_connection(
|
||||
use_tls=test_request.use_tls,
|
||||
username=test_request.username,
|
||||
encrypted_password="", # Not used for test
|
||||
forward_to="test@test.com"
|
||||
forward_to="test@test.com",
|
||||
)
|
||||
|
||||
processor = MailProcessor(temp_account, test_request.password)
|
||||
success, message = await processor.test_connection()
|
||||
|
||||
return MailAccountTestResponse(
|
||||
success=success,
|
||||
message=message
|
||||
)
|
||||
return MailAccountTestResponse(success=success, message=message)
|
||||
|
||||
|
||||
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
|
||||
@@ -220,6 +220,5 @@ async def auto_detect_mail_settings(
|
||||
suggestions = MailServerAutoDetect.detect(detect_request.email_address)
|
||||
|
||||
return MailAccountAutoDetectResponse(
|
||||
success=len(suggestions) > 0,
|
||||
suggestions=suggestions
|
||||
success=len(suggestions) > 0, suggestions=suggestions
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Notification configuration endpoints"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,23 +9,24 @@ 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
|
||||
NotificationConfigCreate,
|
||||
NotificationConfigResponse,
|
||||
NotificationConfigUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
|
||||
@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)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create notification configuration"""
|
||||
config = NotificationConfig(
|
||||
user_id=current_user.id,
|
||||
**config_in.dict()
|
||||
)
|
||||
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
@@ -34,7 +36,7 @@ async def create_notification_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)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all notification configurations"""
|
||||
result = await db.execute(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Provider presets and Gmail credential management endpoints"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -156,7 +157,11 @@ async def get_provider_preset(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/gmail-credential", response_model=GmailCredentialResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"/gmail-credential",
|
||||
response_model=GmailCredentialResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def save_gmail_credential(
|
||||
credential_in: GmailCredentialCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -189,7 +194,9 @@ async def save_gmail_credential(
|
||||
|
||||
encrypted_access = encrypt_credential(credential_in.access_token)
|
||||
encrypted_refresh = (
|
||||
encrypt_credential(credential_in.refresh_token) if credential_in.refresh_token else None
|
||||
encrypt_credential(credential_in.refresh_token)
|
||||
if credential_in.refresh_token
|
||||
else None
|
||||
)
|
||||
|
||||
if existing:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Subscription and payment endpoints"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,9 +14,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||
async def list_subscription_plans(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
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)
|
||||
@@ -25,11 +24,11 @@ async def list_subscription_plans(
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_subscription(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
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
|
||||
"expires_at": current_user.subscription_expires_at,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""User management endpoints"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,7 +13,7 @@ router = APIRouter()
|
||||
|
||||
@router.get("/me", response_model=UserDetailResponse)
|
||||
async def get_current_user_profile(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Get current user profile"""
|
||||
return current_user
|
||||
@@ -22,7 +23,7 @@ async def get_current_user_profile(
|
||||
async def update_current_user_profile(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update current user profile"""
|
||||
if user_update.email:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Application configuration using Pydantic settings.
|
||||
Supports environment variables and .env files.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import PostgresDsn, field_validator, ValidationInfo
|
||||
@@ -11,10 +12,7 @@ class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore"
|
||||
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
# Application
|
||||
@@ -28,7 +26,9 @@ class Settings(BaseSettings):
|
||||
PORT: int = 8000
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
|
||||
DATABASE_URL: str = (
|
||||
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
|
||||
)
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Database configuration and session management.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from app.core.config import settings
|
||||
|
||||
+12
-15
@@ -1,9 +1,14 @@
|
||||
"""
|
||||
Authentication dependencies for FastAPI.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.security import (
|
||||
OAuth2PasswordBearer,
|
||||
HTTPBearer,
|
||||
HTTPAuthorizationCredentials,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -19,7 +24,7 @@ http_bearer = HTTPBearer(auto_error=False)
|
||||
async def get_current_user(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(http_bearer),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated user from JWT token.
|
||||
@@ -75,8 +80,7 @@ async def get_current_user(
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -88,8 +92,7 @@ async def get_current_active_user(
|
||||
"""Get current active user"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@@ -100,8 +103,7 @@ async def get_current_superuser(
|
||||
"""Get current superuser"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@@ -111,12 +113,7 @@ def check_subscription_tier(required_tier: str):
|
||||
Dependency factory to check if user has required subscription tier.
|
||||
Returns a dependency function.
|
||||
"""
|
||||
tier_hierarchy = {
|
||||
"free": 0,
|
||||
"basic": 1,
|
||||
"pro": 2,
|
||||
"enterprise": 3
|
||||
}
|
||||
tier_hierarchy = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
|
||||
|
||||
async def check_tier(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
user_tier_level = tier_hierarchy.get(current_user.subscription_tier.value, 0)
|
||||
@@ -125,7 +122,7 @@ def check_subscription_tier(required_tier: str):
|
||||
if user_tier_level < required_tier_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f"This feature requires {required_tier} subscription or higher"
|
||||
detail=f"This feature requires {required_tier} subscription or higher",
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Security middleware for adding security headers and CSRF protection.
|
||||
"""
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
@@ -25,7 +26,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# Strict Transport Security (HTTPS only)
|
||||
# Note: Only enable in production with HTTPS
|
||||
if request.url.hostname not in ["localhost", "127.0.0.1"]:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
|
||||
# Content Security Policy (adjust based on frontend needs)
|
||||
csp = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Security utilities for encryption, hashing, and token generation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
@@ -14,7 +15,6 @@ import base64
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -29,17 +29,23 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
def create_access_token(
|
||||
data: Dict[str, Any], expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""Create JWT access token"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
@@ -48,14 +54,18 @@ def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and validate JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -84,10 +94,10 @@ class CredentialEncryption:
|
||||
|
||||
# Generate salt - unique per user for enhanced security
|
||||
if user_id is not None:
|
||||
salt = hashlib.sha256(f'pop3fwd_usr_{user_id}'.encode()).digest()[:16]
|
||||
salt = hashlib.sha256(f"pop3fwd_usr_{user_id}".encode()).digest()[:16]
|
||||
else:
|
||||
# Default salt for system-wide operations (use with caution)
|
||||
salt = b'pop3_forwarder_0'
|
||||
salt = b"pop3_forwarder_0"
|
||||
|
||||
# Derive a proper Fernet key from the provided key
|
||||
kdf = PBKDF2HMAC(
|
||||
@@ -96,20 +106,20 @@ class CredentialEncryption:
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
)
|
||||
key_bytes = key.encode('utf-8')
|
||||
key_bytes = key.encode("utf-8")
|
||||
derived_key = base64.urlsafe_b64encode(kdf.derive(key_bytes))
|
||||
self.fernet = Fernet(derived_key)
|
||||
|
||||
def encrypt(self, plain_text: str) -> str:
|
||||
"""Encrypt a string and return base64-encoded ciphertext"""
|
||||
encrypted = self.fernet.encrypt(plain_text.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
encrypted = self.fernet.encrypt(plain_text.encode("utf-8"))
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
def decrypt(self, encrypted_text: str) -> str:
|
||||
"""Decrypt a base64-encoded ciphertext"""
|
||||
encrypted_bytes = base64.b64decode(encrypted_text.encode('utf-8'))
|
||||
encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8"))
|
||||
decrypted = self.fernet.decrypt(encrypted_bytes)
|
||||
return decrypted.decode('utf-8')
|
||||
return decrypted.decode("utf-8")
|
||||
|
||||
|
||||
# Global encryption instance
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Main FastAPI application.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
@@ -14,7 +15,7 @@ from app.api.v1.api import api_router
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper()),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,7 +30,7 @@ def create_application() -> FastAPI:
|
||||
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json"
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
# Security middleware (add before CORS)
|
||||
@@ -54,7 +55,7 @@ def create_application() -> FastAPI:
|
||||
return {
|
||||
"message": "POP3 Forwarder SaaS API",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/api/docs"
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
"""Models package"""
|
||||
|
||||
from app.models.database_models import (
|
||||
User, MailAccount, ProcessingRun, ProcessingLog,
|
||||
NotificationConfig, MailServerPreset, SubscriptionPlan, AuditLog,
|
||||
SubscriptionTier, MailProtocol, AccountStatus, NotificationChannel
|
||||
User,
|
||||
MailAccount,
|
||||
ProcessingRun,
|
||||
ProcessingLog,
|
||||
NotificationConfig,
|
||||
MailServerPreset,
|
||||
SubscriptionPlan,
|
||||
AuditLog,
|
||||
SubscriptionTier,
|
||||
MailProtocol,
|
||||
AccountStatus,
|
||||
NotificationChannel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"User", "MailAccount", "ProcessingRun", "ProcessingLog",
|
||||
"NotificationConfig", "MailServerPreset", "SubscriptionPlan", "AuditLog",
|
||||
"SubscriptionTier", "MailProtocol", "AccountStatus", "NotificationChannel"
|
||||
"User",
|
||||
"MailAccount",
|
||||
"ProcessingRun",
|
||||
"ProcessingLog",
|
||||
"NotificationConfig",
|
||||
"MailServerPreset",
|
||||
"SubscriptionPlan",
|
||||
"AuditLog",
|
||||
"SubscriptionTier",
|
||||
"MailProtocol",
|
||||
"AccountStatus",
|
||||
"NotificationChannel",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"""
|
||||
Database models for the multi-tenant POP3 forwarder application.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, DateTime, ForeignKey,
|
||||
Text, Enum as SQLEnum, JSON, Float, Index
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Text,
|
||||
Enum as SQLEnum,
|
||||
JSON,
|
||||
Float,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
@@ -15,6 +25,7 @@ from app.core.database import Base
|
||||
|
||||
class SubscriptionTier(str, enum.Enum):
|
||||
"""Subscription tier levels"""
|
||||
|
||||
FREE = "free"
|
||||
BASIC = "basic"
|
||||
PRO = "pro"
|
||||
@@ -23,6 +34,7 @@ class SubscriptionTier(str, enum.Enum):
|
||||
|
||||
class MailProtocol(str, enum.Enum):
|
||||
"""Supported mail protocols"""
|
||||
|
||||
POP3 = "pop3"
|
||||
POP3_SSL = "pop3_ssl"
|
||||
IMAP = "imap"
|
||||
@@ -31,12 +43,14 @@ class MailProtocol(str, enum.Enum):
|
||||
|
||||
class DeliveryMethod(str, enum.Enum):
|
||||
"""How emails are delivered to Gmail"""
|
||||
|
||||
SMTP = "smtp" # Forward via SMTP (legacy)
|
||||
GMAIL_API = "gmail_api" # Inject via Gmail API (preferred)
|
||||
|
||||
|
||||
class AccountStatus(str, enum.Enum):
|
||||
"""Mail account status"""
|
||||
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
ERROR = "error"
|
||||
@@ -45,6 +59,7 @@ class AccountStatus(str, enum.Enum):
|
||||
|
||||
class NotificationChannel(str, enum.Enum):
|
||||
"""Notification channel types"""
|
||||
|
||||
EMAIL = "email"
|
||||
TELEGRAM = "telegram"
|
||||
WEBHOOK = "webhook"
|
||||
@@ -54,11 +69,14 @@ class NotificationChannel(str, enum.Enum):
|
||||
|
||||
class User(Base):
|
||||
"""User model - represents a user account"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=True) # Nullable for OAuth-only users
|
||||
hashed_password = Column(
|
||||
String(255), nullable=True
|
||||
) # Nullable for OAuth-only users
|
||||
full_name = Column(String(255))
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
@@ -69,28 +87,41 @@ class User(Base):
|
||||
|
||||
# Subscription
|
||||
subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE)
|
||||
subscription_status = Column(String(50), default="active") # active, canceled, past_due
|
||||
subscription_status = Column(
|
||||
String(50), default="active"
|
||||
) # active, canceled, past_due
|
||||
stripe_customer_id = Column(String(255), unique=True, nullable=True)
|
||||
stripe_subscription_id = Column(String(255), unique=True, nullable=True)
|
||||
subscription_expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
mail_accounts = relationship("MailAccount", back_populates="user", cascade="all, delete-orphan")
|
||||
notifications = relationship("NotificationConfig", back_populates="user", cascade="all, delete-orphan")
|
||||
logs = relationship("ProcessingLog", back_populates="user", cascade="all, delete-orphan")
|
||||
mail_accounts = relationship(
|
||||
"MailAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
notifications = relationship(
|
||||
"NotificationConfig", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
logs = relationship(
|
||||
"ProcessingLog", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class MailAccount(Base):
|
||||
"""Mail account configuration (POP3/IMAP)"""
|
||||
|
||||
__tablename__ = "mail_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Account details
|
||||
name = Column(String(255), nullable=False) # User-friendly name
|
||||
@@ -134,25 +165,32 @@ class MailAccount(Base):
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="mail_accounts")
|
||||
processing_runs = relationship("ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan")
|
||||
processing_runs = relationship(
|
||||
"ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_email', 'user_id', 'email_address'),
|
||||
Index('idx_status_enabled', 'status', 'is_enabled'),
|
||||
Index("idx_user_email", "user_id", "email_address"),
|
||||
Index("idx_status_enabled", "status", "is_enabled"),
|
||||
)
|
||||
|
||||
|
||||
class ProcessingRun(Base):
|
||||
"""Records of email processing runs for each mail account"""
|
||||
|
||||
__tablename__ = "processing_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
mail_account_id = Column(
|
||||
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Run details
|
||||
started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
@@ -172,19 +210,24 @@ class ProcessingRun(Base):
|
||||
mail_account = relationship("MailAccount", back_populates="processing_runs")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_account_started', 'mail_account_id', 'started_at'),
|
||||
)
|
||||
__table_args__ = (Index("idx_account_started", "mail_account_id", "started_at"),)
|
||||
|
||||
|
||||
class ProcessingLog(Base):
|
||||
"""Detailed logs of individual email processing attempts"""
|
||||
|
||||
__tablename__ = "processing_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
processing_run_id = Column(Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
mail_account_id = Column(
|
||||
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
processing_run_id = Column(
|
||||
Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
|
||||
# Log details
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
@@ -205,17 +248,20 @@ class ProcessingLog(Base):
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_timestamp', 'user_id', 'timestamp'),
|
||||
Index('idx_account_timestamp', 'mail_account_id', 'timestamp'),
|
||||
Index("idx_user_timestamp", "user_id", "timestamp"),
|
||||
Index("idx_account_timestamp", "mail_account_id", "timestamp"),
|
||||
)
|
||||
|
||||
|
||||
class NotificationConfig(Base):
|
||||
"""User notification channel configurations"""
|
||||
|
||||
__tablename__ = "notification_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Channel details
|
||||
channel = Column(SQLEnum(NotificationChannel), nullable=False)
|
||||
@@ -235,19 +281,20 @@ class NotificationConfig(Base):
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="notifications")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_channel', 'user_id', 'channel'),
|
||||
)
|
||||
__table_args__ = (Index("idx_user_channel", "user_id", "channel"),)
|
||||
|
||||
|
||||
class MailServerPreset(Base):
|
||||
"""Predefined mail server configurations for common providers"""
|
||||
|
||||
__tablename__ = "mail_server_presets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -270,11 +317,14 @@ class MailServerPreset(Base):
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionPlan(Base):
|
||||
"""Available subscription plans and their features"""
|
||||
|
||||
__tablename__ = "subscription_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -296,7 +346,9 @@ class SubscriptionPlan(Base):
|
||||
max_mail_accounts = Column(Integer, nullable=False)
|
||||
max_emails_per_day = Column(Integer, nullable=False)
|
||||
check_interval_minutes = Column(Integer, nullable=False)
|
||||
support_level = Column(String(50), default="community") # community, email, priority
|
||||
support_level = Column(
|
||||
String(50), default="community"
|
||||
) # community, email, priority
|
||||
features = Column(JSON, nullable=True) # Additional features as JSON
|
||||
|
||||
# Status
|
||||
@@ -304,17 +356,22 @@ class SubscriptionPlan(Base):
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Audit trail for security and compliance"""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Who
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
user_email = Column(String(255), nullable=True) # Cached for deleted users
|
||||
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
|
||||
|
||||
@@ -332,17 +389,20 @@ class AuditLog(Base):
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_action', 'user_id', 'action'),
|
||||
Index('idx_timestamp_action', 'timestamp', 'action'),
|
||||
Index("idx_user_action", "user_id", "action"),
|
||||
Index("idx_timestamp_action", "timestamp", "action"),
|
||||
)
|
||||
|
||||
|
||||
class GmailCredential(Base):
|
||||
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
|
||||
|
||||
__tablename__ = "gmail_credentials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
|
||||
# Gmail account email
|
||||
gmail_email = Column(String(255), nullable=False)
|
||||
@@ -361,7 +421,9 @@ class GmailCredential(Base):
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="gmail_credential")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Pydantic schemas for API request/response validation.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, EmailStr, Field, validator
|
||||
@@ -156,6 +157,7 @@ class MailAccountResponse(MailAccountBase):
|
||||
|
||||
class MailAccountTestRequest(BaseModel):
|
||||
"""Test connection to mail server"""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
protocol: MailProtocol
|
||||
@@ -173,6 +175,7 @@ class MailAccountTestResponse(BaseModel):
|
||||
|
||||
class MailAccountAutoDetectRequest(BaseModel):
|
||||
"""Auto-detect mail server settings"""
|
||||
|
||||
email_address: EmailStr
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
OAuth2 authentication service for Google and other providers.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
@@ -26,14 +27,16 @@ class OAuthService:
|
||||
"""Register Google OAuth2 provider"""
|
||||
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
||||
self.oauth.register(
|
||||
name='google',
|
||||
name="google",
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||
client_kwargs={'scope': 'openid email profile'}
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
async def get_google_user_info(self, code: str, redirect_uri: str) -> Dict[str, Any]:
|
||||
async def get_google_user_info(
|
||||
self, code: str, redirect_uri: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange Google authorization code for user information.
|
||||
|
||||
@@ -48,53 +51,55 @@ class OAuthService:
|
||||
# Exchange code for token
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
'code': code,
|
||||
'client_id': settings.GOOGLE_CLIENT_ID,
|
||||
'client_secret': settings.GOOGLE_CLIENT_SECRET,
|
||||
'redirect_uri': redirect_uri,
|
||||
'grant_type': 'authorization_code'
|
||||
}
|
||||
"code": code,
|
||||
"client_id": settings.GOOGLE_CLIENT_ID,
|
||||
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Google token exchange failed: {token_response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code"
|
||||
detail="Failed to exchange authorization code",
|
||||
)
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get('access_token')
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No access token received"
|
||||
detail="No access token received",
|
||||
)
|
||||
|
||||
# Get user info
|
||||
user_info_response = await client.get(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
headers={'Authorization': f'Bearer {access_token}'}
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if user_info_response.status_code != 200:
|
||||
logger.error(f"Google user info fetch failed: {user_info_response.text}")
|
||||
logger.error(
|
||||
f"Google user info fetch failed: {user_info_response.text}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to get user information"
|
||||
detail="Failed to get user information",
|
||||
)
|
||||
|
||||
user_info = user_info_response.json()
|
||||
|
||||
return {
|
||||
'email': user_info.get('email'),
|
||||
'full_name': user_info.get('name'),
|
||||
'google_id': user_info.get('id'),
|
||||
'picture': user_info.get('picture'),
|
||||
'verified_email': user_info.get('verified_email', False)
|
||||
"email": user_info.get("email"),
|
||||
"full_name": user_info.get("name"),
|
||||
"google_id": user_info.get("id"),
|
||||
"picture": user_info.get("picture"),
|
||||
"verified_email": user_info.get("verified_email", False),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@@ -103,7 +108,7 @@ class OAuthService:
|
||||
logger.error(f"OAuth error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="OAuth authentication failed"
|
||||
detail="OAuth authentication failed",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -123,7 +128,7 @@ class OAuthService:
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer"
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Uses the Gmail API's users.messages.insert() method to inject emails
|
||||
into a user's Gmail account, preserving original headers and metadata.
|
||||
This is preferred over SMTP forwarding as it doesn't modify the email.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
@@ -25,6 +26,7 @@ GMAIL_SCOPES = [
|
||||
|
||||
class GmailInjectionError(Exception):
|
||||
"""Raised when Gmail API injection fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -129,7 +131,9 @@ class GmailService:
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
error_msg = f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
error_msg = (
|
||||
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
except Exception as e:
|
||||
@@ -149,9 +153,7 @@ class GmailService:
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.getProfile(userId="me")
|
||||
.execute(),
|
||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
||||
)
|
||||
email = result.get("emailAddress", "unknown")
|
||||
logger.info(f"Gmail API access verified for: {email}")
|
||||
@@ -172,9 +174,7 @@ class GmailService:
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.getProfile(userId="me")
|
||||
.execute(),
|
||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
||||
)
|
||||
return result.get("emailAddress")
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Mail processing service for fetching and forwarding emails.
|
||||
Supports both POP3 and IMAP protocols with secure connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import poplib
|
||||
import smtplib
|
||||
@@ -23,21 +24,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class MailConnectionError(Exception):
|
||||
"""Raised when unable to connect to mail server"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MailAuthenticationError(Exception):
|
||||
"""Raised when authentication fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MailFetchError(Exception):
|
||||
"""Raised when fetching emails fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MailForwardError(Exception):
|
||||
"""Raised when forwarding email fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -75,13 +80,11 @@ class MailProcessor:
|
||||
self.account.host,
|
||||
self.account.port,
|
||||
context=context,
|
||||
timeout=10
|
||||
timeout=10,
|
||||
)
|
||||
else:
|
||||
pop_conn = poplib.POP3(
|
||||
self.account.host,
|
||||
self.account.port,
|
||||
timeout=10
|
||||
self.account.host, self.account.port, timeout=10
|
||||
)
|
||||
|
||||
# Try authentication
|
||||
@@ -112,15 +115,11 @@ class MailProcessor:
|
||||
# Create IMAP client
|
||||
if self.account.protocol == MailProtocol.IMAP_SSL:
|
||||
imap_client = aioimaplib.IMAP4_SSL(
|
||||
host=self.account.host,
|
||||
port=self.account.port,
|
||||
timeout=10
|
||||
host=self.account.host, port=self.account.port, timeout=10
|
||||
)
|
||||
else:
|
||||
imap_client = aioimaplib.IMAP4(
|
||||
host=self.account.host,
|
||||
port=self.account.port,
|
||||
timeout=10
|
||||
host=self.account.host, port=self.account.port, timeout=10
|
||||
)
|
||||
|
||||
await imap_client.wait_hello_from_server()
|
||||
@@ -128,14 +127,14 @@ class MailProcessor:
|
||||
# Authenticate
|
||||
response = await imap_client.login(self.account.username, self.password)
|
||||
|
||||
if response.result != 'OK':
|
||||
if response.result != "OK":
|
||||
return False, f"Authentication failed: {response.lines}"
|
||||
|
||||
# Select inbox
|
||||
await imap_client.select('INBOX')
|
||||
await imap_client.select("INBOX")
|
||||
|
||||
# Get message count
|
||||
response = await imap_client.search('ALL')
|
||||
response = await imap_client.search("ALL")
|
||||
message_ids = response.lines[0].split()
|
||||
message_count = len(message_ids)
|
||||
|
||||
@@ -173,13 +172,11 @@ class MailProcessor:
|
||||
self.account.host,
|
||||
self.account.port,
|
||||
context=context,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
else:
|
||||
pop_conn = poplib.POP3(
|
||||
self.account.host,
|
||||
self.account.port,
|
||||
timeout=30
|
||||
self.account.host, self.account.port, timeout=30
|
||||
)
|
||||
|
||||
# Authenticate
|
||||
@@ -188,7 +185,9 @@ class MailProcessor:
|
||||
|
||||
# Get message count
|
||||
num_messages = len(pop_conn.list()[1])
|
||||
logger.info(f"Found {num_messages} messages for account {self.account.id}")
|
||||
logger.info(
|
||||
f"Found {num_messages} messages for account {self.account.id}"
|
||||
)
|
||||
|
||||
fetched_emails = []
|
||||
messages_to_delete = []
|
||||
@@ -197,10 +196,12 @@ class MailProcessor:
|
||||
for i in range(1, min(num_messages + 1, max_count + 1)):
|
||||
try:
|
||||
response, lines, octets = pop_conn.retr(i)
|
||||
email_data = b'\r\n'.join(lines)
|
||||
email_data = b"\r\n".join(lines)
|
||||
fetched_emails.append(email_data)
|
||||
messages_to_delete.append(i)
|
||||
logger.info(f"Retrieved message {i} from account {self.account.id}")
|
||||
logger.info(
|
||||
f"Retrieved message {i} from account {self.account.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving message {i}: {e}")
|
||||
|
||||
@@ -231,45 +232,43 @@ class MailProcessor:
|
||||
# Create IMAP client
|
||||
if self.account.protocol == MailProtocol.IMAP_SSL:
|
||||
imap_client = aioimaplib.IMAP4_SSL(
|
||||
host=self.account.host,
|
||||
port=self.account.port,
|
||||
timeout=30
|
||||
host=self.account.host, port=self.account.port, timeout=30
|
||||
)
|
||||
else:
|
||||
imap_client = aioimaplib.IMAP4(
|
||||
host=self.account.host,
|
||||
port=self.account.port,
|
||||
timeout=30
|
||||
host=self.account.host, port=self.account.port, timeout=30
|
||||
)
|
||||
|
||||
await imap_client.wait_hello_from_server()
|
||||
await imap_client.login(self.account.username, self.password)
|
||||
await imap_client.select('INBOX')
|
||||
await imap_client.select("INBOX")
|
||||
|
||||
# Search for all messages
|
||||
response = await imap_client.search('UNSEEN') # Only fetch unread
|
||||
response = await imap_client.search("UNSEEN") # Only fetch unread
|
||||
message_ids = response.lines[0].split()
|
||||
|
||||
# Limit to max_count
|
||||
message_ids = message_ids[:max_count]
|
||||
|
||||
logger.info(f"Found {len(message_ids)} unread messages for account {self.account.id}")
|
||||
logger.info(
|
||||
f"Found {len(message_ids)} unread messages for account {self.account.id}"
|
||||
)
|
||||
|
||||
# Fetch each message
|
||||
for msg_id in message_ids:
|
||||
try:
|
||||
response = await imap_client.fetch(msg_id, '(RFC822)')
|
||||
response = await imap_client.fetch(msg_id, "(RFC822)")
|
||||
|
||||
# Extract email data from response
|
||||
email_data = None
|
||||
for line in response.lines:
|
||||
if isinstance(line, bytes) and b'RFC822' in line:
|
||||
if isinstance(line, bytes) and b"RFC822" in line:
|
||||
# Find the email content
|
||||
start_idx = line.find(b'{')
|
||||
start_idx = line.find(b"{")
|
||||
if start_idx != -1:
|
||||
# Email data is in the next parts
|
||||
continue
|
||||
elif isinstance(line, bytes) and not line.startswith(b'*'):
|
||||
elif isinstance(line, bytes) and not line.startswith(b"*"):
|
||||
email_data = line
|
||||
break
|
||||
|
||||
@@ -278,7 +277,7 @@ class MailProcessor:
|
||||
|
||||
# Mark as seen if deleting after forward
|
||||
if self.account.delete_after_forward:
|
||||
await imap_client.store(msg_id, '+FLAGS', '\\Deleted')
|
||||
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching message {msg_id}: {e}")
|
||||
@@ -300,7 +299,7 @@ class MailProcessor:
|
||||
email_data: bytes,
|
||||
source_account_name: str,
|
||||
destination: str,
|
||||
smtp_config: Dict[str, Any]
|
||||
smtp_config: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Forward an email to the destination address.
|
||||
@@ -327,15 +326,17 @@ class MailProcessor:
|
||||
msg = parser.BytesParser().parsebytes(email_data)
|
||||
|
||||
# Create forwarding message
|
||||
forward_msg = MIMEMultipart('mixed')
|
||||
forward_msg['From'] = smtp_config['username']
|
||||
forward_msg['To'] = destination
|
||||
forward_msg['Date'] = formatdate(localtime=True)
|
||||
forward_msg['Message-ID'] = make_msgid()
|
||||
forward_msg = MIMEMultipart("mixed")
|
||||
forward_msg["From"] = smtp_config["username"]
|
||||
forward_msg["To"] = destination
|
||||
forward_msg["Date"] = formatdate(localtime=True)
|
||||
forward_msg["Message-ID"] = make_msgid()
|
||||
|
||||
# Preserve original subject with prefix
|
||||
original_subject = msg.get('Subject', 'No Subject')
|
||||
forward_msg['Subject'] = f"[Fwd from {source_account_name}] {original_subject}"
|
||||
original_subject = msg.get("Subject", "No Subject")
|
||||
forward_msg["Subject"] = (
|
||||
f"[Fwd from {source_account_name}] {original_subject}"
|
||||
)
|
||||
|
||||
# Add original headers
|
||||
header_info = f"Originally from: {msg.get('From', 'Unknown')}\n"
|
||||
@@ -349,26 +350,32 @@ class MailProcessor:
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||
body = part.get_payload(decode=True).decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
break
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
body = payload.decode('utf-8', errors='ignore')
|
||||
body = payload.decode("utf-8", errors="ignore")
|
||||
|
||||
# Combine header and body
|
||||
full_body = header_info + body
|
||||
forward_msg.attach(MIMEText(full_body, 'plain', 'utf-8'))
|
||||
forward_msg.attach(MIMEText(full_body, "plain", "utf-8"))
|
||||
|
||||
# Send via SMTP
|
||||
if smtp_config.get('use_tls', True):
|
||||
server = smtplib.SMTP(smtp_config['host'], smtp_config['port'], timeout=30)
|
||||
if smtp_config.get("use_tls", True):
|
||||
server = smtplib.SMTP(
|
||||
smtp_config["host"], smtp_config["port"], timeout=30
|
||||
)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port'], timeout=30)
|
||||
server = smtplib.SMTP_SSL(
|
||||
smtp_config["host"], smtp_config["port"], timeout=30
|
||||
)
|
||||
|
||||
try:
|
||||
server.login(smtp_config['username'], smtp_config['password'])
|
||||
server.login(smtp_config["username"], smtp_config["password"])
|
||||
server.send_message(forward_msg)
|
||||
logger.info(f"Successfully forwarded email to {destination}")
|
||||
return True
|
||||
@@ -543,7 +550,7 @@ class MailServerAutoDetect:
|
||||
Detect mail server settings for an email address.
|
||||
Returns list of possible configurations.
|
||||
"""
|
||||
domain = email_address.split('@')[-1].lower()
|
||||
domain = email_address.split("@")[-1].lower()
|
||||
|
||||
suggestions = []
|
||||
|
||||
@@ -553,28 +560,33 @@ class MailServerAutoDetect:
|
||||
|
||||
# Add POP3 SSL suggestion
|
||||
if "pop3_ssl" in provider:
|
||||
suggestions.append({
|
||||
suggestions.append(
|
||||
{
|
||||
"protocol": "pop3_ssl",
|
||||
"provider_name": provider["name"],
|
||||
"host": provider["pop3_ssl"]["host"],
|
||||
"port": provider["pop3_ssl"]["port"],
|
||||
"use_ssl": True,
|
||||
"use_tls": False,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Add IMAP SSL suggestion
|
||||
if "imap_ssl" in provider:
|
||||
suggestions.append({
|
||||
suggestions.append(
|
||||
{
|
||||
"protocol": "imap_ssl",
|
||||
"provider_name": provider["name"],
|
||||
"host": provider["imap_ssl"]["host"],
|
||||
"port": provider["imap_ssl"]["port"],
|
||||
"use_ssl": True,
|
||||
"use_tls": False,
|
||||
})
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Generic suggestions based on common patterns
|
||||
suggestions.extend([
|
||||
suggestions.extend(
|
||||
[
|
||||
{
|
||||
"protocol": "pop3_ssl",
|
||||
"provider_name": "Generic",
|
||||
@@ -607,6 +619,7 @@ class MailServerAutoDetect:
|
||||
"use_ssl": True,
|
||||
"use_tls": False,
|
||||
},
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
return suggestions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Celery application for background email processing tasks.
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
import logging
|
||||
@@ -14,7 +15,7 @@ celery_app = Celery(
|
||||
"pop3_forwarder",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=["app.workers.tasks"]
|
||||
include=["app.workers.tasks"],
|
||||
)
|
||||
|
||||
# Celery configuration
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Celery tasks for background email processing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,8 +13,12 @@ from app.workers.celery_app import celery_app
|
||||
from app.core.database import async_session_maker
|
||||
from app.core.security import decrypt_credential
|
||||
from app.models.database_models import (
|
||||
MailAccount, ProcessingRun, ProcessingLog, AccountStatus,
|
||||
DeliveryMethod, GmailCredential,
|
||||
MailAccount,
|
||||
ProcessingRun,
|
||||
ProcessingLog,
|
||||
AccountStatus,
|
||||
DeliveryMethod,
|
||||
GmailCredential,
|
||||
)
|
||||
from app.services.mail_processor import MailProcessor
|
||||
from app.services.gmail_service import GmailService, GmailInjectionError
|
||||
@@ -57,7 +62,7 @@ async def process_mail_account(account_id: int):
|
||||
run = ProcessingRun(
|
||||
mail_account_id=account.id,
|
||||
started_at=datetime.utcnow(),
|
||||
status="running"
|
||||
status="running",
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
@@ -79,9 +84,7 @@ async def process_mail_account(account_id: int):
|
||||
emails_failed = 0
|
||||
|
||||
# Determine delivery method
|
||||
use_gmail_api = (
|
||||
account.delivery_method == DeliveryMethod.GMAIL_API
|
||||
)
|
||||
use_gmail_api = account.delivery_method == DeliveryMethod.GMAIL_API
|
||||
|
||||
gmail_service = None
|
||||
smtp_config = None
|
||||
@@ -123,11 +126,13 @@ async def process_mail_account(account_id: int):
|
||||
"port": int(os.getenv("SMTP_PORT", "587")),
|
||||
"username": os.getenv("SMTP_USER", ""),
|
||||
"password": os.getenv("SMTP_PASSWORD", ""),
|
||||
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true"
|
||||
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
||||
}
|
||||
|
||||
if not smtp_config["username"] or not smtp_config["password"]:
|
||||
logger.error(f"SMTP credentials not configured for account {account.id}")
|
||||
logger.error(
|
||||
f"SMTP credentials not configured for account {account.id}"
|
||||
)
|
||||
run.status = "failed"
|
||||
run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)"
|
||||
await db.commit()
|
||||
@@ -146,10 +151,7 @@ async def process_mail_account(account_id: int):
|
||||
else:
|
||||
# Forward via SMTP (fallback)
|
||||
success = await MailProcessor.forward_email(
|
||||
email_data,
|
||||
account.name,
|
||||
account.forward_to,
|
||||
smtp_config
|
||||
email_data, account.name, account.forward_to, smtp_config
|
||||
)
|
||||
if success:
|
||||
emails_forwarded += 1
|
||||
@@ -191,14 +193,16 @@ async def process_mail_account(account_id: int):
|
||||
logger.error(f"Error processing account {account_id}: {e}")
|
||||
|
||||
# Mark run as failed
|
||||
if 'run' in locals():
|
||||
if "run" in locals():
|
||||
run.status = "failed"
|
||||
run.error_message = str(e)
|
||||
run.completed_at = datetime.utcnow()
|
||||
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
|
||||
run.duration_seconds = (
|
||||
run.completed_at - run.started_at
|
||||
).total_seconds()
|
||||
|
||||
# Update account error status
|
||||
if 'account' in locals():
|
||||
if "account" in locals():
|
||||
account.status = AccountStatus.ERROR
|
||||
account.last_error_at = datetime.utcnow()
|
||||
account.last_error_message = str(e)
|
||||
@@ -219,7 +223,9 @@ async def process_all_enabled_accounts():
|
||||
select(MailAccount).where(
|
||||
and_(
|
||||
MailAccount.is_enabled == True,
|
||||
MailAccount.status.in_([AccountStatus.ACTIVE, AccountStatus.TESTING])
|
||||
MailAccount.status.in_(
|
||||
[AccountStatus.ACTIVE, AccountStatus.TESTING]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -232,7 +238,9 @@ async def process_all_enabled_accounts():
|
||||
# Check if it's time to check this account
|
||||
if account.last_check_at:
|
||||
time_since_last_check = datetime.utcnow() - account.last_check_at
|
||||
if time_since_last_check.total_seconds() < (account.check_interval_minutes * 60):
|
||||
if time_since_last_check.total_seconds() < (
|
||||
account.check_interval_minutes * 60
|
||||
):
|
||||
logger.debug(f"Skipping account {account.id} - not time yet")
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the backend directory is on sys.path so that `app` is importable
|
||||
# when pytest is invoked from the backend/ directory (e.g., `cd backend && pytest tests/`).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test configuration and fixtures.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
@@ -15,7 +16,9 @@ from app.models.database_models import User
|
||||
from app.core.security import get_password_hash, create_access_token
|
||||
|
||||
# Test database URL (use different database for tests)
|
||||
TEST_DATABASE_URL = settings.DATABASE_URL.replace("/pop3_forwarder", "/pop3_forwarder_test")
|
||||
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
|
||||
"/pop3_forwarder", "/pop3_forwarder_test"
|
||||
)
|
||||
|
||||
|
||||
# Note: event_loop fixture removed - pytest-asyncio provides this automatically
|
||||
@@ -120,9 +123,11 @@ def admin_auth_headers(test_admin_user: User) -> dict:
|
||||
|
||||
# Factory fixtures for creating test data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_factory(db_session: AsyncSession):
|
||||
"""Factory for creating test users"""
|
||||
|
||||
async def _create_user(
|
||||
email: str = None,
|
||||
password: str = "testpassword123",
|
||||
@@ -132,6 +137,7 @@ def user_factory(db_session: AsyncSession):
|
||||
) -> User:
|
||||
if email is None:
|
||||
import uuid
|
||||
|
||||
email = f"test-{uuid.uuid4()}@example.com"
|
||||
|
||||
user = User(
|
||||
@@ -166,6 +172,7 @@ def mail_account_factory(db_session: AsyncSession):
|
||||
) -> MailAccount:
|
||||
if username is None:
|
||||
import uuid
|
||||
|
||||
username = f"test-{uuid.uuid4()}@example.com"
|
||||
|
||||
encrypted_password = encrypt_password(password, user_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for configuration module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from app.core.config import Settings
|
||||
@@ -56,5 +57,11 @@ class TestConfigValidation:
|
||||
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-encryption",
|
||||
)
|
||||
|
||||
assert settings.SECRET_KEY == "this-is-a-secure-32-character-key-for-testing-secret"
|
||||
assert settings.ENCRYPTION_KEY == "this-is-a-secure-32-character-key-for-encryption"
|
||||
assert (
|
||||
settings.SECRET_KEY
|
||||
== "this-is-a-secure-32-character-key-for-testing-secret"
|
||||
)
|
||||
assert (
|
||||
settings.ENCRYPTION_KEY
|
||||
== "this-is-a-secure-32-character-key-for-encryption"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for Gmail service module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||
@@ -91,7 +92,9 @@ class TestGmailService:
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().messages().insert().execute.side_effect = Exception("API Error")
|
||||
mock_api.users().messages().insert().execute.side_effect = Exception(
|
||||
"API Error"
|
||||
)
|
||||
service._service = mock_api
|
||||
|
||||
with pytest.raises(GmailInjectionError, match="Failed to inject email"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for provider presets and mail server auto-detection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.services.mail_processor import MailServerAutoDetect
|
||||
|
||||
@@ -156,11 +157,13 @@ class TestProviderPresets:
|
||||
def test_provider_presets_import(self):
|
||||
"""Test that provider presets can be imported"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
assert len(PROVIDER_PRESETS) > 0
|
||||
|
||||
def test_all_presets_have_required_fields(self):
|
||||
"""Test that all presets have required fields"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
for preset in PROVIDER_PRESETS:
|
||||
assert preset.id
|
||||
assert preset.name
|
||||
@@ -171,6 +174,7 @@ class TestProviderPresets:
|
||||
def test_gmail_preset_exists(self):
|
||||
"""Test that Gmail preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
gmail = next((p for p in PROVIDER_PRESETS if p.id == "gmail"), None)
|
||||
assert gmail is not None
|
||||
assert gmail.imap_ssl is not None
|
||||
@@ -179,6 +183,7 @@ class TestProviderPresets:
|
||||
def test_gmx_preset_exists(self):
|
||||
"""Test that GMX preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
gmx = next((p for p in PROVIDER_PRESETS if p.id == "gmx"), None)
|
||||
assert gmx is not None
|
||||
assert "gmx.de" in gmx.domains
|
||||
@@ -186,6 +191,7 @@ class TestProviderPresets:
|
||||
def test_webde_preset_exists(self):
|
||||
"""Test that WEB.DE preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
webde = next((p for p in PROVIDER_PRESETS if p.id == "webde"), None)
|
||||
assert webde is not None
|
||||
assert "web.de" in webde.domains
|
||||
@@ -193,6 +199,7 @@ class TestProviderPresets:
|
||||
def test_outlook_preset_exists(self):
|
||||
"""Test that Outlook preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
outlook = next((p for p in PROVIDER_PRESETS if p.id == "outlook"), None)
|
||||
assert outlook is not None
|
||||
assert "hotmail.com" in outlook.domains
|
||||
@@ -200,17 +207,20 @@ class TestProviderPresets:
|
||||
def test_yahoo_preset_exists(self):
|
||||
"""Test that Yahoo preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
yahoo = next((p for p in PROVIDER_PRESETS if p.id == "yahoo"), None)
|
||||
assert yahoo is not None
|
||||
|
||||
def test_aol_preset_exists(self):
|
||||
"""Test that AOL preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
aol = next((p for p in PROVIDER_PRESETS if p.id == "aol"), None)
|
||||
assert aol is not None
|
||||
|
||||
def test_tonline_preset_exists(self):
|
||||
"""Test that T-Online preset is included"""
|
||||
from app.api.v1.endpoints.providers import PROVIDER_PRESETS
|
||||
|
||||
tonline = next((p for p in PROVIDER_PRESETS if p.id == "tonline"), None)
|
||||
assert tonline is not None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for security module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
@@ -48,7 +49,7 @@ class TestJWT:
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 50
|
||||
assert token.count('.') == 2 # JWT has 3 parts
|
||||
assert token.count(".") == 2 # JWT has 3 parts
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
|
||||
Reference in New Issue
Block a user