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:
copilot-swe-agent[bot]
2026-03-23 10:24:28 +00:00
parent 681e0582f6
commit bcbef88803
29 changed files with 863 additions and 693 deletions
+2 -4
View File
@@ -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()
+22 -5
View File
@@ -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"])
+3 -2
View File
@@ -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,
}
+24 -35
View File
@@ -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)
+30 -31
View File
@@ -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
)
+10 -8
View File
@@ -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(
+9 -2
View File
@@ -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,
}
+3 -2
View File
@@ -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:
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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 = (
+23 -13
View File
@@ -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
View File
@@ -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")
+25 -6
View File
@@ -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",
]
+99 -37
View File
@@ -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)
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")
+3
View File
@@ -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
+30 -25
View File
@@ -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",
}
+7 -7
View File
@@ -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:
+113 -100
View File
@@ -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,60 +560,66 @@ class MailServerAutoDetect:
# Add POP3 SSL suggestion
if "pop3_ssl" in provider:
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,
})
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({
"protocol": "imap_ssl",
"provider_name": provider["name"],
"host": provider["imap_ssl"]["host"],
"port": provider["imap_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
})
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([
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop3.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"imap.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"mail.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
])
suggestions.extend(
[
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop3.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"imap.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"mail.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
]
)
return suggestions
+2 -1
View File
@@ -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
+25 -17
View File
@@ -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
+6
View File
@@ -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))
+8 -1
View File
@@ -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)
+9 -2
View File
@@ -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"
)
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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: