Merge pull request #18 from christianlouis/copilot/add-account-management-functionality

Fix CI: pytest module resolution, black formatting, authlib security vulnerabilities
This commit is contained in:
Christian Krakau-Louis
2026-03-23 11:33:12 +01:00
committed by GitHub
32 changed files with 2417 additions and 859 deletions
+2 -4
View File
@@ -1,4 +1,5 @@
"""Alembic environment configuration""" """Alembic environment configuration"""
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from alembic import context from alembic import context
@@ -49,10 +50,7 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(connection=connection, target_metadata=target_metadata)
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
+22 -4
View File
@@ -1,16 +1,34 @@
""" """
API v1 router aggregation. API v1 router aggregation.
""" """
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, mail_accounts, notifications, subscriptions, admin from app.api.v1.endpoints import (
auth,
users,
mail_accounts,
notifications,
subscriptions,
admin,
providers,
)
api_router = APIRouter() api_router = APIRouter()
# Include all endpoint routers # Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"]) 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(
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"]) mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"]
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"]) )
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"]) api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
+7 -6
View File
@@ -1,4 +1,5 @@
"""Admin endpoints""" """Admin endpoints"""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -13,24 +14,24 @@ router = APIRouter()
@router.get("/stats") @router.get("/stats")
async def get_admin_stats( async def get_admin_stats(
current_user: User = Depends(get_current_superuser), current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Get overall system statistics (admin only)""" """Get overall system statistics (admin only)"""
# Count users # Count users
user_count = await db.execute(select(func.count(User.id))) user_count = await db.execute(select(func.count(User.id)))
total_users = user_count.scalar() total_users = user_count.scalar()
# Count accounts # Count accounts
account_count = await db.execute(select(func.count(MailAccount.id))) account_count = await db.execute(select(func.count(MailAccount.id)))
total_accounts = account_count.scalar() total_accounts = account_count.scalar()
# Count processing runs # Count processing runs
run_count = await db.execute(select(func.count(ProcessingRun.id))) run_count = await db.execute(select(func.count(ProcessingRun.id)))
total_runs = run_count.scalar() total_runs = run_count.scalar()
return { return {
"total_users": total_users, "total_users": total_users,
"total_mail_accounts": total_accounts, "total_mail_accounts": total_accounts,
"total_processing_runs": total_runs "total_processing_runs": total_runs,
} }
+51 -62
View File
@@ -1,6 +1,7 @@
""" """
Authentication endpoints (login, register, OAuth). Authentication endpoints (login, register, OAuth).
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,72 +12,65 @@ import logging
from app.core.database import get_db from app.core.database import get_db
from app.core.security import verify_password, get_password_hash from app.core.security import verify_password, get_password_hash
from app.models.database_models import User, SubscriptionTier from app.models.database_models import User, SubscriptionTier
from app.models.schemas import ( from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
Token, UserCreate, UserResponse, GoogleAuthRequest
)
from app.services.auth_service import oauth_service from app.services.auth_service import oauth_service
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post(
async def register( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
user_in: UserCreate, )
db: AsyncSession = Depends(get_db) async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
):
"""Register a new user with email and password""" """Register a new user with email and password"""
# Check if user exists # Check if user exists
result = await db.execute( result = await db.execute(select(User).where(User.email == user_in.email))
select(User).where(User.email == user_in.email)
)
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
detail="Email already registered"
) )
# Create new user # Create new user
user = User( user = User(
email=user_in.email, email=user_in.email,
full_name=user_in.full_name, 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, subscription_tier=SubscriptionTier.FREE,
is_active=True is_active=True,
) )
db.add(user) db.add(user)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
logger.info(f"New user registered: {user.email}") logger.info(f"New user registered: {user.email}")
return user return user
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login( async def login(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db)
): ):
"""Login with email and password""" """Login with email and password"""
# Get user # Get user
result = await db.execute( result = await db.execute(select(User).where(User.email == form_data.username))
select(User).where(User.email == form_data.username)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not user.hashed_password: if not user or not user.hashed_password:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password", detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Verify password # Verify password
if not verify_password(form_data.password, user.hashed_password): if not verify_password(form_data.password, user.hashed_password):
raise HTTPException( raise HTTPException(
@@ -84,90 +78,85 @@ async def login(
detail="Incorrect email or password", detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Check if user is active # Check if user is active
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
detail="User account is inactive"
) )
# Update last login # Update last login
user.last_login_at = datetime.utcnow() user.last_login_at = datetime.utcnow()
await db.commit() await db.commit()
# Create tokens # Create tokens
tokens = oauth_service.create_tokens_for_user(user) tokens = oauth_service.create_tokens_for_user(user)
logger.info(f"User logged in: {user.email}") logger.info(f"User logged in: {user.email}")
return tokens return tokens
@router.post("/google", response_model=Token) @router.post("/google", response_model=Token)
async def google_oauth( async def google_oauth(
auth_request: GoogleAuthRequest, auth_request: GoogleAuthRequest, db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db)
): ):
""" """
Authenticate with Google OAuth2. Authenticate with Google OAuth2.
Exchange authorization code for access token and user info. Exchange authorization code for access token and user info.
""" """
# Get user info from Google # Get user info from Google
user_info = await oauth_service.get_google_user_info( user_info = await oauth_service.get_google_user_info(
code=auth_request.code, code=auth_request.code, redirect_uri=auth_request.redirect_uri
redirect_uri=auth_request.redirect_uri
) )
if not user_info.get('verified_email'): if not user_info.get("verified_email"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google" detail="Email not verified with Google",
) )
email = user_info['email'] email = user_info["email"]
google_id = user_info['google_id'] google_id = user_info["google_id"]
# Check if user exists # Check if user exists
result = await db.execute( result = await db.execute(
select(User).where( select(User).where((User.email == email) | (User.google_id == google_id))
(User.email == email) | (User.google_id == google_id)
)
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user: if user:
# Update Google ID if not set # Update Google ID if not set
if not user.google_id: if not user.google_id:
user.google_id = google_id user.google_id = google_id
user.oauth_provider = "google" user.oauth_provider = "google"
# Update last login # Update last login
user.last_login_at = datetime.utcnow() user.last_login_at = datetime.utcnow()
logger.info(f"Existing user logged in with Google: {user.email}") logger.info(f"Existing user logged in with Google: {user.email}")
else: else:
# Create new user # Create new user
user = User( user = User(
email=email, email=email,
full_name=user_info.get('full_name'), full_name=user_info.get("full_name"),
google_id=google_id, google_id=google_id,
oauth_provider="google", oauth_provider="google",
subscription_tier=SubscriptionTier.FREE, subscription_tier=SubscriptionTier.FREE,
is_active=True, is_active=True,
last_login_at=datetime.utcnow() last_login_at=datetime.utcnow(),
) )
db.add(user) db.add(user)
logger.info(f"New user registered with Google: {user.email}") logger.info(f"New user registered with Google: {user.email}")
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
# Create tokens # Create tokens
tokens = oauth_service.create_tokens_for_user(user) tokens = oauth_service.create_tokens_for_user(user)
return tokens return tokens
@@ -175,7 +164,7 @@ async def google_oauth(
async def get_google_authorize_url(redirect_uri: str): async def get_google_authorize_url(redirect_uri: str):
"""Get Google OAuth2 authorization URL""" """Get Google OAuth2 authorization URL"""
from app.core.config import settings from app.core.config import settings
auth_url = ( auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?" f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={settings.GOOGLE_CLIENT_ID}&" f"client_id={settings.GOOGLE_CLIENT_ID}&"
@@ -184,5 +173,5 @@ async def get_google_authorize_url(redirect_uri: str):
f"redirect_uri={redirect_uri}&" f"redirect_uri={redirect_uri}&"
f"access_type=offline" f"access_type=offline"
) )
return {"authorization_url": auth_url} return {"authorization_url": auth_url}
+54 -54
View File
@@ -1,4 +1,5 @@
"""Mail account management endpoints""" """Mail account management endpoints"""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.core.security import encrypt_credential, decrypt_credential
from app.models.database_models import User, MailAccount from app.models.database_models import User, MailAccount
from app.models.schemas import ( from app.models.schemas import (
MailAccountCreate, MailAccountResponse, MailAccountUpdate, MailAccountCreate,
MailAccountTestRequest, MailAccountTestResponse, MailAccountResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectResponse MailAccountUpdate,
MailAccountTestRequest,
MailAccountTestResponse,
MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse,
) )
from app.services.mail_processor import MailProcessor, MailServerAutoDetect from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings from app.core.config import settings
@@ -19,38 +24,40 @@ from app.core.config import settings
router = APIRouter() 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( async def create_mail_account(
account_in: MailAccountCreate, account_in: MailAccountCreate,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Create a new mail account""" """Create a new mail account"""
# Check subscription limits # Check subscription limits
result = await db.execute( result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id) select(MailAccount).where(MailAccount.user_id == current_user.id)
) )
existing_accounts = result.scalars().all() existing_accounts = result.scalars().all()
tier_limits = { tier_limits = {
"free": settings.TIER_FREE_MAX_ACCOUNTS, "free": settings.TIER_FREE_MAX_ACCOUNTS,
"basic": settings.TIER_BASIC_MAX_ACCOUNTS, "basic": settings.TIER_BASIC_MAX_ACCOUNTS,
"pro": settings.TIER_PRO_MAX_ACCOUNTS, "pro": settings.TIER_PRO_MAX_ACCOUNTS,
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS, "enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
} }
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1) max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
if len(existing_accounts) >= max_accounts: if len(existing_accounts) >= max_accounts:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED, 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 # Encrypt password
encrypted_password = encrypt_credential(account_in.password) encrypted_password = encrypt_credential(account_in.password)
# Create account # Create account
account = MailAccount( account = MailAccount(
user_id=current_user.id, user_id=current_user.id,
@@ -64,23 +71,24 @@ async def create_mail_account(
username=account_in.username, username=account_in.username,
encrypted_password=encrypted_password, encrypted_password=encrypted_password,
forward_to=account_in.forward_to, forward_to=account_in.forward_to,
delivery_method=account_in.delivery_method,
is_enabled=account_in.is_enabled, is_enabled=account_in.is_enabled,
check_interval_minutes=account_in.check_interval_minutes, check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check, 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) db.add(account)
await db.commit() await db.commit()
await db.refresh(account) await db.refresh(account)
return account return account
@router.get("", response_model=List[MailAccountResponse]) @router.get("", response_model=List[MailAccountResponse])
async def list_mail_accounts( async def list_mail_accounts(
current_user: User = Depends(get_current_active_user), 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""" """List all mail accounts for current user"""
result = await db.execute( result = await db.execute(
@@ -96,23 +104,21 @@ async def list_mail_accounts(
async def get_mail_account( async def get_mail_account(
account_id: int, account_id: int,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Get a specific mail account""" """Get a specific mail account"""
result = await db.execute( result = await db.execute(
select(MailAccount).where( select(MailAccount).where(
MailAccount.id == account_id, MailAccount.id == account_id, MailAccount.user_id == current_user.id
MailAccount.user_id == current_user.id
) )
) )
account = result.scalar_one_or_none() account = result.scalar_one_or_none()
if not account: if not account:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
detail="Mail account not found"
) )
return account return account
@@ -121,35 +127,35 @@ async def update_mail_account(
account_id: int, account_id: int,
account_update: MailAccountUpdate, account_update: MailAccountUpdate,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Update a mail account""" """Update a mail account"""
result = await db.execute( result = await db.execute(
select(MailAccount).where( select(MailAccount).where(
MailAccount.id == account_id, MailAccount.id == account_id, MailAccount.user_id == current_user.id
MailAccount.user_id == current_user.id
) )
) )
account = result.scalar_one_or_none() account = result.scalar_one_or_none()
if not account: if not account:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
detail="Mail account not found"
) )
# Update fields # Update fields
update_data = account_update.dict(exclude_unset=True) update_data = account_update.dict(exclude_unset=True)
if "password" in update_data: 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(): for field, value in update_data.items():
setattr(account, field, value) setattr(account, field, value)
await db.commit() await db.commit()
await db.refresh(account) await db.refresh(account)
return account return account
@@ -157,23 +163,21 @@ async def update_mail_account(
async def delete_mail_account( async def delete_mail_account(
account_id: int, account_id: int,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Delete a mail account""" """Delete a mail account"""
result = await db.execute( result = await db.execute(
select(MailAccount).where( select(MailAccount).where(
MailAccount.id == account_id, MailAccount.id == account_id, MailAccount.user_id == current_user.id
MailAccount.user_id == current_user.id
) )
) )
account = result.scalar_one_or_none() account = result.scalar_one_or_none()
if not account: if not account:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
detail="Mail account not found"
) )
await db.delete(account) await db.delete(account)
await db.commit() await db.commit()
@@ -184,7 +188,7 @@ async def test_mail_connection(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
): ):
"""Test connection to mail server""" """Test connection to mail server"""
# Create temporary account for testing # Create temporary account for testing
temp_account = MailAccount( temp_account = MailAccount(
user_id=current_user.id, user_id=current_user.id,
@@ -197,16 +201,13 @@ async def test_mail_connection(
use_tls=test_request.use_tls, use_tls=test_request.use_tls,
username=test_request.username, username=test_request.username,
encrypted_password="", # Not used for test encrypted_password="", # Not used for test
forward_to="test@test.com" forward_to="test@test.com",
) )
processor = MailProcessor(temp_account, test_request.password) processor = MailProcessor(temp_account, test_request.password)
success, message = await processor.test_connection() success, message = await processor.test_connection()
return MailAccountTestResponse( return MailAccountTestResponse(success=success, message=message)
success=success,
message=message
)
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse) @router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
@@ -215,10 +216,9 @@ async def auto_detect_mail_settings(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
): ):
"""Auto-detect mail server settings for an email address""" """Auto-detect mail server settings for an email address"""
suggestions = MailServerAutoDetect.detect(detect_request.email_address) suggestions = MailServerAutoDetect.detect(detect_request.email_address)
return MailAccountAutoDetectResponse( return MailAccountAutoDetectResponse(
success=len(suggestions) > 0, success=len(suggestions) > 0, suggestions=suggestions
suggestions=suggestions
) )
+10 -8
View File
@@ -1,4 +1,5 @@
"""Notification configuration endpoints""" """Notification configuration endpoints"""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig from app.models.database_models import User, NotificationConfig
from app.models.schemas import ( from app.models.schemas import (
NotificationConfigCreate, NotificationConfigResponse, NotificationConfigUpdate NotificationConfigCreate,
NotificationConfigResponse,
NotificationConfigUpdate,
) )
router = APIRouter() 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( async def create_notification_config(
config_in: NotificationConfigCreate, config_in: NotificationConfigCreate,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Create notification configuration""" """Create notification configuration"""
config = NotificationConfig( config = NotificationConfig(user_id=current_user.id, **config_in.dict())
user_id=current_user.id,
**config_in.dict()
)
db.add(config) db.add(config)
await db.commit() await db.commit()
await db.refresh(config) await db.refresh(config)
@@ -34,7 +36,7 @@ async def create_notification_config(
@router.get("", response_model=List[NotificationConfigResponse]) @router.get("", response_model=List[NotificationConfigResponse])
async def list_notification_configs( async def list_notification_configs(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""List all notification configurations""" """List all notification configurations"""
result = await db.execute( result = await db.execute(
+266
View File
@@ -0,0 +1,266 @@
"""Provider presets and Gmail credential management endpoints"""
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential, decrypt_credential
from app.core.config import settings
from app.models.database_models import User, GmailCredential
from app.models.schemas import (
ProviderPreset,
ProviderListResponse,
GmailCredentialCreate,
GmailCredentialResponse,
)
from app.services.gmail_service import GmailService
router = APIRouter()
# Provider presets with server configurations
PROVIDER_PRESETS: List[ProviderPreset] = [
ProviderPreset(
id="gmail",
name="Gmail",
icon="gmail",
domains=["gmail.com", "googlemail.com"],
imap_ssl={"host": "imap.gmail.com", "port": 993},
pop3_ssl={"host": "pop.gmail.com", "port": 995},
notes="Enable IMAP/POP3 in Gmail settings. Use an App Password if 2FA is enabled.",
),
ProviderPreset(
id="gmx",
name="GMX",
icon="gmx",
domains=["gmx.de", "gmx.net", "gmx.at", "gmx.ch", "gmx.com"],
imap_ssl={"host": "imap.gmx.net", "port": 993},
pop3_ssl={"host": "pop.gmx.net", "port": 995},
notes="Enable POP3/IMAP in GMX settings under E-Mail > POP3/IMAP Abruf.",
),
ProviderPreset(
id="webde",
name="WEB.DE",
icon="webde",
domains=["web.de"],
imap_ssl={"host": "imap.web.de", "port": 993},
pop3_ssl={"host": "pop3.web.de", "port": 995},
notes="Enable POP3/IMAP in WEB.DE settings under E-Mail > POP3/IMAP Abruf.",
),
ProviderPreset(
id="outlook",
name="Outlook / Hotmail",
icon="outlook",
domains=["outlook.com", "hotmail.com", "live.com", "msn.com", "outlook.de"],
imap_ssl={"host": "outlook.office365.com", "port": 993},
pop3_ssl={"host": "outlook.office365.com", "port": 995},
notes="Use your Microsoft account credentials.",
),
ProviderPreset(
id="yahoo",
name="Yahoo Mail",
icon="yahoo",
domains=["yahoo.com", "yahoo.de", "yahoo.co.uk", "ymail.com"],
imap_ssl={"host": "imap.mail.yahoo.com", "port": 993},
pop3_ssl={"host": "pop.mail.yahoo.com", "port": 995},
notes="Generate an App Password in Yahoo account security settings.",
),
ProviderPreset(
id="aol",
name="AOL Mail",
icon="aol",
domains=["aol.com", "aim.com"],
imap_ssl={"host": "imap.aol.com", "port": 993},
pop3_ssl={"host": "pop.aol.com", "port": 995},
notes="Generate an App Password in AOL account security settings.",
),
ProviderPreset(
id="tonline",
name="T-Online",
icon="tonline",
domains=["t-online.de"],
imap_ssl={"host": "secureimap.t-online.de", "port": 993},
pop3_ssl={"host": "securepop.t-online.de", "port": 995},
notes="Use your T-Online E-Mail-Passwort (not your Telekom login password).",
),
ProviderPreset(
id="ionos",
name="1&1 / IONOS",
icon="ionos",
domains=["online.de", "onlinehome.de", "1und1.de"],
imap_ssl={"host": "imap.ionos.de", "port": 993},
pop3_ssl={"host": "pop.ionos.de", "port": 995},
notes="Use your IONOS email credentials.",
),
ProviderPreset(
id="freenet",
name="Freenet",
icon="freenet",
domains=["freenet.de"],
imap_ssl={"host": "mx.freenet.de", "port": 993},
pop3_ssl={"host": "mx.freenet.de", "port": 995},
notes="Use your Freenet email credentials.",
),
ProviderPreset(
id="posteo",
name="Posteo",
icon="posteo",
domains=["posteo.de", "posteo.net"],
imap_ssl={"host": "posteo.de", "port": 993},
pop3_ssl=None,
notes="Posteo supports IMAP only. Use your Posteo credentials.",
),
ProviderPreset(
id="mailde",
name="mail.de",
icon="mailde",
domains=["mail.de"],
imap_ssl={"host": "imap.mail.de", "port": 993},
pop3_ssl={"host": "pop.mail.de", "port": 995},
notes="Use your mail.de email credentials.",
),
ProviderPreset(
id="icloud",
name="iCloud Mail",
icon="icloud",
domains=["icloud.com", "me.com", "mac.com"],
imap_ssl={"host": "imap.mail.me.com", "port": 993},
pop3_ssl=None,
notes="Generate an app-specific password at appleid.apple.com.",
),
]
@router.get("/presets", response_model=ProviderListResponse)
async def list_provider_presets(
current_user: User = Depends(get_current_active_user),
):
"""List all available mail provider presets for quick setup wizard"""
return ProviderListResponse(providers=PROVIDER_PRESETS)
@router.get("/presets/{provider_id}", response_model=ProviderPreset)
async def get_provider_preset(
provider_id: str,
current_user: User = Depends(get_current_active_user),
):
"""Get a specific provider preset by ID"""
for preset in PROVIDER_PRESETS:
if preset.id == provider_id:
return preset
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider '{provider_id}' not found",
)
@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),
db: AsyncSession = Depends(get_db),
):
"""
Save Gmail API OAuth2 credentials for the current user.
These are used to inject emails directly into Gmail via the API.
"""
# Verify the credentials work
gmail_service = GmailService(
access_token=credential_in.access_token,
refresh_token=credential_in.refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
is_valid = await gmail_service.verify_access()
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Gmail API credentials are invalid or expired",
)
# Check for existing credential
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
existing = result.scalar_one_or_none()
encrypted_access = encrypt_credential(credential_in.access_token)
encrypted_refresh = (
encrypt_credential(credential_in.refresh_token)
if credential_in.refresh_token
else None
)
if existing:
# Update existing
existing.gmail_email = credential_in.gmail_email
existing.encrypted_access_token = encrypted_access
existing.encrypted_refresh_token = encrypted_refresh
existing.is_valid = True
existing.last_verified_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing
else:
# Create new
credential = GmailCredential(
user_id=current_user.id,
gmail_email=credential_in.gmail_email,
encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh,
is_valid=True,
last_verified_at=datetime.utcnow(),
)
db.add(credential)
await db.commit()
await db.refresh(credential)
return credential
@router.get("/gmail-credential", response_model=GmailCredentialResponse)
async def get_gmail_credential(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's Gmail API credential status"""
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Gmail credentials configured. Set up Gmail API access first.",
)
return credential
@router.delete("/gmail-credential", status_code=status.HTTP_204_NO_CONTENT)
async def delete_gmail_credential(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Delete the current user's Gmail API credentials"""
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Gmail credentials found",
)
await db.delete(credential)
await db.commit()
@@ -1,4 +1,5 @@
"""Subscription and payment endpoints""" """Subscription and payment endpoints"""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +14,7 @@ router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse]) @router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans( async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
db: AsyncSession = Depends(get_db)
):
"""List all available subscription plans""" """List all available subscription plans"""
result = await db.execute( result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) select(SubscriptionPlan).where(SubscriptionPlan.is_active == True)
@@ -25,11 +24,11 @@ async def list_subscription_plans(
@router.get("/current") @router.get("/current")
async def get_current_subscription( 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""" """Get current user's subscription details"""
return { return {
"tier": current_user.subscription_tier, "tier": current_user.subscription_tier,
"status": current_user.subscription_status, "status": current_user.subscription_status,
"expires_at": current_user.subscription_expires_at "expires_at": current_user.subscription_expires_at,
} }
+4 -3
View File
@@ -1,4 +1,5 @@
"""User management endpoints""" """User management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,7 @@ router = APIRouter()
@router.get("/me", response_model=UserDetailResponse) @router.get("/me", response_model=UserDetailResponse)
async def get_current_user_profile( 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""" """Get current user profile"""
return current_user return current_user
@@ -22,14 +23,14 @@ async def get_current_user_profile(
async def update_current_user_profile( async def update_current_user_profile(
user_update: UserUpdate, user_update: UserUpdate,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
): ):
"""Update current user profile""" """Update current user profile"""
if user_update.email: if user_update.email:
current_user.email = user_update.email current_user.email = user_update.email
if user_update.full_name: if user_update.full_name:
current_user.full_name = user_update.full_name current_user.full_name = user_update.full_name
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return current_user return current_user
+29 -25
View File
@@ -2,6 +2,7 @@
Application configuration using Pydantic settings. Application configuration using Pydantic settings.
Supports environment variables and .env files. Supports environment variables and .env files.
""" """
from typing import Optional, List from typing import Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import PostgresDsn, field_validator, ValidationInfo from pydantic import PostgresDsn, field_validator, ValidationInfo
@@ -9,82 +10,85 @@ from pydantic import PostgresDsn, field_validator, ValidationInfo
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables""" """Application settings loaded from environment variables"""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
) )
# Application # Application
APP_NAME: str = "POP3 Forwarder SaaS" APP_NAME: str = "POP3 Forwarder SaaS"
APP_VERSION: str = "2.0.0" APP_VERSION: str = "2.0.0"
DEBUG: bool = False DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1" API_V1_PREFIX: str = "/api/v1"
# Server # Server
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
PORT: int = 8000 PORT: int = 8000
# Database # 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_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 DATABASE_MAX_OVERFLOW: int = 10
# Security # Security
SECRET_KEY: str = "change-this-to-a-secure-random-secret-key-in-production" SECRET_KEY: str = "change-this-to-a-secure-random-secret-key-in-production"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Encryption (for storing POP3/IMAP credentials) # Encryption (for storing POP3/IMAP credentials)
ENCRYPTION_KEY: str = "change-this-to-a-secure-encryption-key" ENCRYPTION_KEY: str = "change-this-to-a-secure-encryption-key"
# OAuth2 - Google # OAuth2 - Google
GOOGLE_CLIENT_ID: Optional[str] = None GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None GOOGLE_CLIENT_SECRET: Optional[str] = None
GOOGLE_REDIRECT_URI: str = "http://localhost:3000/auth/callback/google" GOOGLE_REDIRECT_URI: str = "http://localhost:3000/auth/callback/google"
# Gmail API (for direct email injection)
GMAIL_API_ENABLED: bool = True
GMAIL_INJECT_LABEL_IDS: List[str] = ["INBOX"]
# CORS # CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
# Stripe Payment # Stripe Payment
STRIPE_API_KEY: Optional[str] = None STRIPE_API_KEY: Optional[str] = None
STRIPE_WEBHOOK_SECRET: Optional[str] = None STRIPE_WEBHOOK_SECRET: Optional[str] = None
STRIPE_PUBLISHABLE_KEY: Optional[str] = None STRIPE_PUBLISHABLE_KEY: Optional[str] = None
# Subscription Tiers # Subscription Tiers
TIER_FREE_MAX_ACCOUNTS: int = 1 TIER_FREE_MAX_ACCOUNTS: int = 1
TIER_BASIC_MAX_ACCOUNTS: int = 5 TIER_BASIC_MAX_ACCOUNTS: int = 5
TIER_PRO_MAX_ACCOUNTS: int = 20 TIER_PRO_MAX_ACCOUNTS: int = 20
TIER_ENTERPRISE_MAX_ACCOUNTS: int = 100 TIER_ENTERPRISE_MAX_ACCOUNTS: int = 100
# Email Processing # Email Processing
MAX_EMAILS_PER_RUN: int = 50 MAX_EMAILS_PER_RUN: int = 50
CHECK_INTERVAL_MINUTES: int = 5 CHECK_INTERVAL_MINUTES: int = 5
THROTTLE_EMAILS_PER_MINUTE: int = 10 THROTTLE_EMAILS_PER_MINUTE: int = 10
# Redis (for Celery and caching) # Redis (for Celery and caching)
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# Celery # Celery
CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
# Apprise (notifications) # Apprise (notifications)
APPRISE_ENABLED: bool = True APPRISE_ENABLED: bool = True
# Logging # Logging
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
# Admin # Admin
ADMIN_EMAIL: Optional[str] = None ADMIN_EMAIL: Optional[str] = None
ADMIN_PASSWORD: Optional[str] = None ADMIN_PASSWORD: Optional[str] = None
# Mail Server Presets # Mail Server Presets
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json" MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
@field_validator("CORS_ORIGINS", mode="before") @field_validator("CORS_ORIGINS", mode="before")
@classmethod @classmethod
def assemble_cors_origins(cls, v: str | List[str]) -> List[str]: def assemble_cors_origins(cls, v: str | List[str]) -> List[str]:
@@ -92,7 +96,7 @@ class Settings(BaseSettings):
if isinstance(v, str): if isinstance(v, str):
return [i.strip() for i in v.split(",")] return [i.strip() for i in v.split(",")]
return v return v
@field_validator("SECRET_KEY") @field_validator("SECRET_KEY")
@classmethod @classmethod
def validate_secret_key(cls, v: str) -> str: def validate_secret_key(cls, v: str) -> str:
@@ -114,7 +118,7 @@ class Settings(BaseSettings):
"Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'" "Generate a secure key with: python -c 'import secrets; print(secrets.token_urlsafe(32))'"
) )
return v return v
@field_validator("ENCRYPTION_KEY") @field_validator("ENCRYPTION_KEY")
@classmethod @classmethod
def validate_encryption_key(cls, v: str) -> str: def validate_encryption_key(cls, v: str) -> str:
+1
View File
@@ -1,6 +1,7 @@
""" """
Database configuration and session management. Database configuration and session management.
""" """
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from app.core.config import settings from app.core.config import settings
+24 -27
View File
@@ -1,9 +1,14 @@
""" """
Authentication dependencies for FastAPI. Authentication dependencies for FastAPI.
""" """
from typing import Optional from typing import Optional
from fastapi import Depends, HTTPException, status 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.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -19,7 +24,7 @@ http_bearer = HTTPBearer(auto_error=False)
async def get_current_user( async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme), token: Optional[str] = Depends(oauth2_scheme),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(http_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Depends(http_bearer),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
) -> User: ) -> User:
""" """
Get current authenticated user from JWT token. Get current authenticated user from JWT token.
@@ -27,14 +32,14 @@ async def get_current_user(
""" """
# Get token from either source # Get token from either source
auth_token = token or (credentials.credentials if credentials else None) auth_token = token or (credentials.credentials if credentials else None)
if not auth_token: if not auth_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated", detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Decode token # Decode token
payload = decode_token(auth_token) payload = decode_token(auth_token)
if not payload: if not payload:
@@ -43,7 +48,7 @@ async def get_current_user(
detail="Invalid authentication credentials", detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Verify token type # Verify token type
token_type = payload.get("type") token_type = payload.get("type")
if token_type != "access": if token_type != "access":
@@ -52,7 +57,7 @@ async def get_current_user(
detail="Invalid token type", detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Get user ID from token # Get user ID from token
user_id: Optional[int] = payload.get("sub") user_id: Optional[int] = payload.get("sub")
if user_id is None: if user_id is None:
@@ -61,24 +66,23 @@ async def get_current_user(
detail="Invalid token payload", detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Fetch user from database # Fetch user from database
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
detail="User account is inactive"
) )
return user return user
@@ -88,8 +92,7 @@ async def get_current_active_user(
"""Get current active user""" """Get current active user"""
if not current_user.is_active: if not current_user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
detail="Inactive user"
) )
return current_user return current_user
@@ -100,8 +103,7 @@ async def get_current_superuser(
"""Get current superuser""" """Get current superuser"""
if not current_user.is_superuser: if not current_user.is_superuser:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
detail="Not enough permissions"
) )
return current_user return current_user
@@ -111,23 +113,18 @@ def check_subscription_tier(required_tier: str):
Dependency factory to check if user has required subscription tier. Dependency factory to check if user has required subscription tier.
Returns a dependency function. Returns a dependency function.
""" """
tier_hierarchy = { tier_hierarchy = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
"free": 0,
"basic": 1,
"pro": 2,
"enterprise": 3
}
async def check_tier(current_user: User = Depends(get_current_active_user)) -> User: 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) user_tier_level = tier_hierarchy.get(current_user.subscription_tier.value, 0)
required_tier_level = tier_hierarchy.get(required_tier, 0) required_tier_level = tier_hierarchy.get(required_tier, 0)
if user_tier_level < required_tier_level: if user_tier_level < required_tier_level:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED, 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 return current_user
return check_tier return check_tier
+21 -18
View File
@@ -1,6 +1,7 @@
""" """
Security middleware for adding security headers and CSRF protection. Security middleware for adding security headers and CSRF protection.
""" """
from fastapi import Request, Response from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp from starlette.types import ASGIApp
@@ -9,24 +10,26 @@ import secrets
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses""" """Add security headers to all responses"""
async def dispatch(self, request: Request, call_next) -> Response: async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request) response = await call_next(request)
# Prevent clickjacking # Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
# Prevent MIME type sniffing # Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Content-Type-Options"] = "nosniff"
# Enable XSS protection (for older browsers) # Enable XSS protection (for older browsers)
response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["X-XSS-Protection"] = "1; mode=block"
# Strict Transport Security (HTTPS only) # Strict Transport Security (HTTPS only)
# Note: Only enable in production with HTTPS # Note: Only enable in production with HTTPS
if request.url.hostname not in ["localhost", "127.0.0.1"]: 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) # Content Security Policy (adjust based on frontend needs)
csp = ( csp = (
"default-src 'self'; " "default-src 'self'; "
@@ -38,15 +41,15 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"frame-src https://js.stripe.com;" "frame-src https://js.stripe.com;"
) )
response.headers["Content-Security-Policy"] = csp response.headers["Content-Security-Policy"] = csp
# Referrer Policy # Referrer Policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions Policy (formerly Feature Policy) # Permissions Policy (formerly Feature Policy)
response.headers["Permissions-Policy"] = ( response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()" "geolocation=(), microphone=(), camera=()"
) )
return response return response
@@ -55,7 +58,7 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
Basic CSRF protection for state-changing operations. Basic CSRF protection for state-changing operations.
For API-only applications, this is less critical but still good practice. For API-only applications, this is less critical but still good practice.
""" """
def __init__(self, app: ASGIApp, exempt_paths: list = None): def __init__(self, app: ASGIApp, exempt_paths: list = None):
super().__init__(app) super().__init__(app)
self.exempt_paths = exempt_paths or [ self.exempt_paths = exempt_paths or [
@@ -66,20 +69,20 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
"/openapi.json", "/openapi.json",
"/health", "/health",
] ]
async def dispatch(self, request: Request, call_next) -> Response: async def dispatch(self, request: Request, call_next) -> Response:
# Skip CSRF check for safe methods # Skip CSRF check for safe methods
if request.method in ["GET", "HEAD", "OPTIONS"]: if request.method in ["GET", "HEAD", "OPTIONS"]:
return await call_next(request) return await call_next(request)
# Skip CSRF check for exempt paths # Skip CSRF check for exempt paths
if any(request.url.path.startswith(path) for path in self.exempt_paths): if any(request.url.path.startswith(path) for path in self.exempt_paths):
return await call_next(request) return await call_next(request)
# For API endpoints using JWT, the token itself provides CSRF protection # For API endpoints using JWT, the token itself provides CSRF protection
# This is because attackers can't access the token stored in httpOnly cookies # This is because attackers can't access the token stored in httpOnly cookies
# or local storage from a different origin # or local storage from a different origin
# If implementing cookie-based sessions, would check CSRF token here: # If implementing cookie-based sessions, would check CSRF token here:
# csrf_token = request.headers.get("X-CSRF-Token") # csrf_token = request.headers.get("X-CSRF-Token")
# if not csrf_token or not self._validate_csrf_token(csrf_token): # if not csrf_token or not self._validate_csrf_token(csrf_token):
@@ -87,15 +90,15 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
# status_code=403, # status_code=403,
# content={"detail": "CSRF token missing or invalid"} # content={"detail": "CSRF token missing or invalid"}
# ) # )
response = await call_next(request) response = await call_next(request)
return response return response
@staticmethod @staticmethod
def _generate_csrf_token() -> str: def _generate_csrf_token() -> str:
"""Generate a secure CSRF token""" """Generate a secure CSRF token"""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
@staticmethod @staticmethod
def _validate_csrf_token(token: str) -> bool: def _validate_csrf_token(token: str) -> bool:
"""Validate CSRF token (implement actual validation logic)""" """Validate CSRF token (implement actual validation logic)"""
+35 -25
View File
@@ -1,6 +1,8 @@
""" """
Security utilities for encryption, hashing, and token generation. Security utilities for encryption, hashing, and token generation.
""" """
import hashlib
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -8,12 +10,11 @@ from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64 import base64
from app.core.config import settings from app.core.config import settings
# Password hashing context # Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -28,17 +29,23 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password) 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""" """Create JWT access token"""
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
else: 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"}) 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 return encoded_jwt
@@ -47,14 +54,18 @@ def create_refresh_token(data: Dict[str, Any]) -> str:
to_encode = data.copy() to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"}) 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 return encoded_jwt
def decode_token(token: str) -> Optional[Dict[str, Any]]: def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode and validate JWT token""" """Decode and validate JWT token"""
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload return payload
except JWTError: except JWTError:
return None return None
@@ -67,49 +78,48 @@ def generate_random_token(length: int = 32) -> str:
class CredentialEncryption: class CredentialEncryption:
"""Handles encryption/decryption of sensitive credentials (POP3/IMAP passwords)""" """Handles encryption/decryption of sensitive credentials (POP3/IMAP passwords)"""
def __init__(self, key: Optional[str] = None, user_id: Optional[int] = None): def __init__(self, key: Optional[str] = None, user_id: Optional[int] = None):
""" """
Initialize encryption with a key. Initialize encryption with a key.
If no key provided, uses the one from settings. If no key provided, uses the one from settings.
In production, use a unique salt per user for enhanced security. In production, use a unique salt per user for enhanced security.
Args: Args:
key: Encryption key (defaults to settings.ENCRYPTION_KEY) key: Encryption key (defaults to settings.ENCRYPTION_KEY)
user_id: Optional user ID for per-user salt generation user_id: Optional user ID for per-user salt generation
""" """
if key is None: if key is None:
key = settings.ENCRYPTION_KEY key = settings.ENCRYPTION_KEY
# Generate salt - in production, this should be unique per user # Generate salt - unique per user for enhanced security
if user_id is not None: if user_id is not None:
# Per-user salt for production salt = hashlib.sha256(f"pop3fwd_usr_{user_id}".encode()).digest()[:16]
salt = f'pop3_forwarder_user_{user_id}'.encode('utf-8')[:16].ljust(16, b'0')
else: else:
# Default salt for system-wide operations (use with caution) # 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 # Derive a proper Fernet key from the provided key
kdf = PBKDF2( kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), algorithm=hashes.SHA256(),
length=32, length=32,
salt=salt, salt=salt,
iterations=100000, iterations=100000,
) )
key_bytes = key.encode('utf-8') key_bytes = key.encode("utf-8")
derived_key = base64.urlsafe_b64encode(kdf.derive(key_bytes)) derived_key = base64.urlsafe_b64encode(kdf.derive(key_bytes))
self.fernet = Fernet(derived_key) self.fernet = Fernet(derived_key)
def encrypt(self, plain_text: str) -> str: def encrypt(self, plain_text: str) -> str:
"""Encrypt a string and return base64-encoded ciphertext""" """Encrypt a string and return base64-encoded ciphertext"""
encrypted = self.fernet.encrypt(plain_text.encode('utf-8')) encrypted = self.fernet.encrypt(plain_text.encode("utf-8"))
return base64.b64encode(encrypted).decode('utf-8') return base64.b64encode(encrypted).decode("utf-8")
def decrypt(self, encrypted_text: str) -> str: def decrypt(self, encrypted_text: str) -> str:
"""Decrypt a base64-encoded ciphertext""" """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) decrypted = self.fernet.decrypt(encrypted_bytes)
return decrypted.decode('utf-8') return decrypted.decode("utf-8")
# Global encryption instance # Global encryption instance
+13 -12
View File
@@ -1,6 +1,7 @@
""" """
Main FastAPI application. Main FastAPI application.
""" """
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -14,7 +15,7 @@ from app.api.v1.api import api_router
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()), 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__) logger = logging.getLogger(__name__)
@@ -22,20 +23,20 @@ logger = logging.getLogger(__name__)
def create_application() -> FastAPI: def create_application() -> FastAPI:
"""Create and configure FastAPI application""" """Create and configure FastAPI application"""
app = FastAPI( app = FastAPI(
title=settings.APP_NAME, title=settings.APP_NAME,
version=settings.APP_VERSION, version=settings.APP_VERSION,
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management", description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
openapi_url="/api/openapi.json" openapi_url="/api/openapi.json",
) )
# Security middleware (add before CORS) # Security middleware (add before CORS)
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(CSRFProtectionMiddleware) app.add_middleware(CSRFProtectionMiddleware)
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -44,36 +45,36 @@ def create_application() -> FastAPI:
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Include API router # Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX) app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/") @app.get("/")
async def root(): async def root():
"""Root endpoint""" """Root endpoint"""
return { return {
"message": "POP3 Forwarder SaaS API", "message": "POP3 Forwarder SaaS API",
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"docs": "/api/docs" "docs": "/api/docs",
} }
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint for container orchestration""" """Health check endpoint for container orchestration"""
return {"status": "healthy"} return {"status": "healthy"}
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Run on application startup""" """Run on application startup"""
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}") logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
logger.info(f"Debug mode: {settings.DEBUG}") logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"API documentation: /api/docs") logger.info(f"API documentation: /api/docs")
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Run on application shutdown""" """Run on application shutdown"""
logger.info("Shutting down application") logger.info("Shutting down application")
return app return app
+25 -6
View File
@@ -1,12 +1,31 @@
"""Models package""" """Models package"""
from app.models.database_models import ( from app.models.database_models import (
User, MailAccount, ProcessingRun, ProcessingLog, User,
NotificationConfig, MailServerPreset, SubscriptionPlan, AuditLog, MailAccount,
SubscriptionTier, MailProtocol, AccountStatus, NotificationChannel ProcessingRun,
ProcessingLog,
NotificationConfig,
MailServerPreset,
SubscriptionPlan,
AuditLog,
SubscriptionTier,
MailProtocol,
AccountStatus,
NotificationChannel,
) )
__all__ = [ __all__ = [
"User", "MailAccount", "ProcessingRun", "ProcessingLog", "User",
"NotificationConfig", "MailServerPreset", "SubscriptionPlan", "AuditLog", "MailAccount",
"SubscriptionTier", "MailProtocol", "AccountStatus", "NotificationChannel" "ProcessingRun",
"ProcessingLog",
"NotificationConfig",
"MailServerPreset",
"SubscriptionPlan",
"AuditLog",
"SubscriptionTier",
"MailProtocol",
"AccountStatus",
"NotificationChannel",
] ]
+187 -86
View File
@@ -1,11 +1,21 @@
""" """
Database models for the multi-tenant POP3 forwarder application. Database models for the multi-tenant POP3 forwarder application.
""" """
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, Boolean, DateTime, ForeignKey, Column,
Text, Enum as SQLEnum, JSON, Float, Index Integer,
String,
Boolean,
DateTime,
ForeignKey,
Text,
Enum as SQLEnum,
JSON,
Float,
Index,
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
import enum import enum
@@ -15,6 +25,7 @@ from app.core.database import Base
class SubscriptionTier(str, enum.Enum): class SubscriptionTier(str, enum.Enum):
"""Subscription tier levels""" """Subscription tier levels"""
FREE = "free" FREE = "free"
BASIC = "basic" BASIC = "basic"
PRO = "pro" PRO = "pro"
@@ -23,14 +34,23 @@ class SubscriptionTier(str, enum.Enum):
class MailProtocol(str, enum.Enum): class MailProtocol(str, enum.Enum):
"""Supported mail protocols""" """Supported mail protocols"""
POP3 = "pop3" POP3 = "pop3"
POP3_SSL = "pop3_ssl" POP3_SSL = "pop3_ssl"
IMAP = "imap" IMAP = "imap"
IMAP_SSL = "imap_ssl" IMAP_SSL = "imap_ssl"
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): class AccountStatus(str, enum.Enum):
"""Mail account status""" """Mail account status"""
ACTIVE = "active" ACTIVE = "active"
INACTIVE = "inactive" INACTIVE = "inactive"
ERROR = "error" ERROR = "error"
@@ -39,6 +59,7 @@ class AccountStatus(str, enum.Enum):
class NotificationChannel(str, enum.Enum): class NotificationChannel(str, enum.Enum):
"""Notification channel types""" """Notification channel types"""
EMAIL = "email" EMAIL = "email"
TELEGRAM = "telegram" TELEGRAM = "telegram"
WEBHOOK = "webhook" WEBHOOK = "webhook"
@@ -48,73 +69,92 @@ class NotificationChannel(str, enum.Enum):
class User(Base): class User(Base):
"""User model - represents a user account""" """User model - represents a user account"""
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False) 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)) full_name = Column(String(255))
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False) is_superuser = Column(Boolean, default=False)
# OAuth # OAuth
google_id = Column(String(255), unique=True, index=True, nullable=True) google_id = Column(String(255), unique=True, index=True, nullable=True)
oauth_provider = Column(String(50), nullable=True) oauth_provider = Column(String(50), nullable=True)
# Subscription # Subscription
subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE) 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_customer_id = Column(String(255), unique=True, nullable=True)
stripe_subscription_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) subscription_expires_at = Column(DateTime, nullable=True)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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) last_login_at = Column(DateTime, nullable=True)
# Relationships # Relationships
mail_accounts = relationship("MailAccount", back_populates="user", cascade="all, delete-orphan") mail_accounts = relationship(
notifications = relationship("NotificationConfig", back_populates="user", cascade="all, delete-orphan") "MailAccount", back_populates="user", cascade="all, delete-orphan"
logs = relationship("ProcessingLog", 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): class MailAccount(Base):
"""Mail account configuration (POP3/IMAP)""" """Mail account configuration (POP3/IMAP)"""
__tablename__ = "mail_accounts" __tablename__ = "mail_accounts"
id = Column(Integer, primary_key=True, index=True) 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 # Account details
name = Column(String(255), nullable=False) # User-friendly name name = Column(String(255), nullable=False) # User-friendly name
email_address = Column(String(255), nullable=False) email_address = Column(String(255), nullable=False)
# Server configuration # Server configuration
protocol = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL) protocol = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL)
host = Column(String(255), nullable=False) host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False) port = Column(Integer, nullable=False)
use_ssl = Column(Boolean, default=True) use_ssl = Column(Boolean, default=True)
use_tls = Column(Boolean, default=False) use_tls = Column(Boolean, default=False)
# Credentials (encrypted) # Credentials (encrypted)
username = Column(String(255), nullable=False) username = Column(String(255), nullable=False)
encrypted_password = Column(Text, nullable=False) encrypted_password = Column(Text, nullable=False)
# Forwarding destination # Forwarding destination
forward_to = Column(String(255), nullable=False) forward_to = Column(String(255), nullable=False)
# Delivery method
delivery_method = Column(SQLEnum(DeliveryMethod), default=DeliveryMethod.GMAIL_API)
# Status and settings # Status and settings
status = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE) status = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE)
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)
check_interval_minutes = Column(Integer, default=5) check_interval_minutes = Column(Integer, default=5)
max_emails_per_check = Column(Integer, default=50) max_emails_per_check = Column(Integer, default=50)
delete_after_forward = Column(Boolean, default=True) delete_after_forward = Column(Boolean, default=True)
# Auto-detection metadata # Auto-detection metadata
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX" provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
auto_detected = Column(Boolean, default=False) auto_detected = Column(Boolean, default=False)
# Statistics # Statistics
total_emails_processed = Column(Integer, default=0) total_emails_processed = Column(Integer, default=0)
total_emails_failed = Column(Integer, default=0) total_emails_failed = Column(Integer, default=0)
@@ -122,131 +162,147 @@ class MailAccount(Base):
last_successful_check_at = Column(DateTime, nullable=True) last_successful_check_at = Column(DateTime, nullable=True)
last_error_at = Column(DateTime, nullable=True) last_error_at = Column(DateTime, nullable=True)
last_error_message = Column(Text, nullable=True) last_error_message = Column(Text, nullable=True)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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 # Relationships
user = relationship("User", back_populates="mail_accounts") 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 # Indexes
__table_args__ = ( __table_args__ = (
Index('idx_user_email', 'user_id', 'email_address'), Index("idx_user_email", "user_id", "email_address"),
Index('idx_status_enabled', 'status', 'is_enabled'), Index("idx_status_enabled", "status", "is_enabled"),
) )
class ProcessingRun(Base): class ProcessingRun(Base):
"""Records of email processing runs for each mail account""" """Records of email processing runs for each mail account"""
__tablename__ = "processing_runs" __tablename__ = "processing_runs"
id = Column(Integer, primary_key=True, index=True) 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 # Run details
started_at = Column(DateTime, default=datetime.utcnow, nullable=False) started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
completed_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True)
duration_seconds = Column(Float, nullable=True) duration_seconds = Column(Float, nullable=True)
# Results # Results
emails_fetched = Column(Integer, default=0) emails_fetched = Column(Integer, default=0)
emails_forwarded = Column(Integer, default=0) emails_forwarded = Column(Integer, default=0)
emails_failed = Column(Integer, default=0) emails_failed = Column(Integer, default=0)
# Status # Status
status = Column(String(50), default="running") # running, completed, failed status = Column(String(50), default="running") # running, completed, failed
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
# Relationships # Relationships
mail_account = relationship("MailAccount", back_populates="processing_runs") mail_account = relationship("MailAccount", back_populates="processing_runs")
# Indexes # Indexes
__table_args__ = ( __table_args__ = (Index("idx_account_started", "mail_account_id", "started_at"),)
Index('idx_account_started', 'mail_account_id', 'started_at'),
)
class ProcessingLog(Base): class ProcessingLog(Base):
"""Detailed logs of individual email processing attempts""" """Detailed logs of individual email processing attempts"""
__tablename__ = "processing_logs" __tablename__ = "processing_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False) Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
processing_run_id = Column(Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True) )
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 # Log details
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
level = Column(String(20), nullable=False) # INFO, WARNING, ERROR level = Column(String(20), nullable=False) # INFO, WARNING, ERROR
message = Column(Text, nullable=False) message = Column(Text, nullable=False)
# Email metadata (if applicable) # Email metadata (if applicable)
email_subject = Column(String(500), nullable=True) email_subject = Column(String(500), nullable=True)
email_from = Column(String(255), nullable=True) email_from = Column(String(255), nullable=True)
email_size_bytes = Column(Integer, nullable=True) email_size_bytes = Column(Integer, nullable=True)
# Status # Status
success = Column(Boolean, default=True) success = Column(Boolean, default=True)
error_details = Column(JSON, nullable=True) error_details = Column(JSON, nullable=True)
# Relationships # Relationships
user = relationship("User", back_populates="logs") user = relationship("User", back_populates="logs")
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
Index('idx_user_timestamp', 'user_id', 'timestamp'), Index("idx_user_timestamp", "user_id", "timestamp"),
Index('idx_account_timestamp', 'mail_account_id', 'timestamp'), Index("idx_account_timestamp", "mail_account_id", "timestamp"),
) )
class NotificationConfig(Base): class NotificationConfig(Base):
"""User notification channel configurations""" """User notification channel configurations"""
__tablename__ = "notification_configs" __tablename__ = "notification_configs"
id = Column(Integer, primary_key=True, index=True) 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 details
channel = Column(SQLEnum(NotificationChannel), nullable=False) channel = Column(SQLEnum(NotificationChannel), nullable=False)
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)
# Channel-specific configuration (stored as JSON) # Channel-specific configuration (stored as JSON)
config = Column(JSON, nullable=False) config = Column(JSON, nullable=False)
# Examples: # Examples:
# EMAIL: {"address": "user@example.com"} # EMAIL: {"address": "user@example.com"}
# TELEGRAM: {"bot_token": "xxx", "chat_id": "yyy"} # TELEGRAM: {"bot_token": "xxx", "chat_id": "yyy"}
# WEBHOOK: {"url": "https://example.com/webhook", "headers": {...}} # WEBHOOK: {"url": "https://example.com/webhook", "headers": {...}}
# Notification preferences # Notification preferences
notify_on_errors = Column(Boolean, default=True) notify_on_errors = Column(Boolean, default=True)
notify_on_success = Column(Boolean, default=False) notify_on_success = Column(Boolean, default=False)
notify_threshold = Column(Integer, default=3) # Notify after N consecutive errors notify_threshold = Column(Integer, default=3) # Notify after N consecutive errors
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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 # Relationships
user = relationship("User", back_populates="notifications") user = relationship("User", back_populates="notifications")
# Indexes # Indexes
__table_args__ = ( __table_args__ = (Index("idx_user_channel", "user_id", "channel"),)
Index('idx_user_channel', 'user_id', 'channel'),
)
class MailServerPreset(Base): class MailServerPreset(Base):
"""Predefined mail server configurations for common providers""" """Predefined mail server configurations for common providers"""
__tablename__ = "mail_server_presets" __tablename__ = "mail_server_presets"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# Provider info # Provider info
provider_name = Column(String(100), unique=True, nullable=False, index=True) provider_name = Column(String(100), unique=True, nullable=False, index=True)
provider_domain = Column(String(255), nullable=False) # e.g., "gmail.com" provider_domain = Column(String(255), nullable=False) # e.g., "gmail.com"
# Server configurations (can have multiple protocols) # Server configurations (can have multiple protocols)
configs = Column(JSON, nullable=False) configs = Column(JSON, nullable=False)
# Example: # Example:
@@ -254,75 +310,120 @@ class MailServerPreset(Base):
# "pop3_ssl": {"host": "pop.gmail.com", "port": 995, "ssl": true}, # "pop3_ssl": {"host": "pop.gmail.com", "port": 995, "ssl": true},
# "imap_ssl": {"host": "imap.gmail.com", "port": 993, "ssl": true} # "imap_ssl": {"host": "imap.gmail.com", "port": 993, "ssl": true}
# } # }
# Metadata # Metadata
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
popularity_score = Column(Integer, default=0) # For sorting recommendations popularity_score = Column(Integer, default=0) # For sorting recommendations
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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): class SubscriptionPlan(Base):
"""Available subscription plans and their features""" """Available subscription plans and their features"""
__tablename__ = "subscription_plans" __tablename__ = "subscription_plans"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# Plan details # Plan details
tier = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False) tier = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
# Pricing # Pricing
price_monthly = Column(Float, nullable=False) price_monthly = Column(Float, nullable=False)
price_yearly = Column(Float, nullable=True) price_yearly = Column(Float, nullable=True)
# Stripe integration # Stripe integration
stripe_price_id_monthly = Column(String(255), nullable=True) stripe_price_id_monthly = Column(String(255), nullable=True)
stripe_price_id_yearly = Column(String(255), nullable=True) stripe_price_id_yearly = Column(String(255), nullable=True)
# Features/Limits # Features/Limits
max_mail_accounts = Column(Integer, nullable=False) max_mail_accounts = Column(Integer, nullable=False)
max_emails_per_day = Column(Integer, nullable=False) max_emails_per_day = Column(Integer, nullable=False)
check_interval_minutes = 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 features = Column(JSON, nullable=True) # Additional features as JSON
# Status # Status
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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): class AuditLog(Base):
"""Audit trail for security and compliance""" """Audit trail for security and compliance"""
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# Who # 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 user_email = Column(String(255), nullable=True) # Cached for deleted users
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6 ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
# What # What
action = Column(String(100), nullable=False, index=True) action = Column(String(100), nullable=False, index=True)
resource_type = Column(String(50), nullable=True) resource_type = Column(String(50), nullable=True)
resource_id = Column(Integer, nullable=True) resource_id = Column(Integer, nullable=True)
# Details # Details
details = Column(JSON, nullable=True) details = Column(JSON, nullable=True)
status = Column(String(20), default="success") # success, failure status = Column(String(20), default="success") # success, failure
# When # When
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
Index('idx_user_action', 'user_id', 'action'), Index("idx_user_action", "user_id", "action"),
Index('idx_timestamp_action', 'timestamp', '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
)
# Gmail account email
gmail_email = Column(String(255), nullable=False)
# OAuth2 tokens (encrypted)
encrypted_access_token = Column(Text, nullable=False)
encrypted_refresh_token = Column(Text, nullable=True)
# Token metadata
token_expiry = Column(DateTime, nullable=True)
scopes = Column(JSON, nullable=True)
# Status
is_valid = Column(Boolean, default=True)
last_verified_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
)
# Relationships
user = relationship("User", backref="gmail_credential")
+55 -9
View File
@@ -1,6 +1,7 @@
""" """
Pydantic schemas for API request/response validation. Pydantic schemas for API request/response validation.
""" """
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from pydantic import BaseModel, EmailStr, Field, validator from pydantic import BaseModel, EmailStr, Field, validator
@@ -29,6 +30,11 @@ class AccountStatus(str, Enum):
TESTING = "testing" TESTING = "testing"
class DeliveryMethod(str, Enum):
SMTP = "smtp"
GMAIL_API = "gmail_api"
class NotificationChannel(str, Enum): class NotificationChannel(str, Enum):
EMAIL = "email" EMAIL = "email"
TELEGRAM = "telegram" TELEGRAM = "telegram"
@@ -58,7 +64,7 @@ class UserResponse(UserBase):
subscription_tier: SubscriptionTier subscription_tier: SubscriptionTier
subscription_status: str subscription_status: str
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
@@ -69,7 +75,7 @@ class UserDetailResponse(UserResponse):
stripe_customer_id: Optional[str] = None stripe_customer_id: Optional[str] = None
subscription_expires_at: Optional[datetime] = None subscription_expires_at: Optional[datetime] = None
last_login_at: Optional[datetime] = None last_login_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -103,6 +109,7 @@ class MailAccountBase(BaseModel):
use_tls: bool = False use_tls: bool = False
username: str = Field(..., max_length=255) username: str = Field(..., max_length=255)
forward_to: EmailStr forward_to: EmailStr
delivery_method: DeliveryMethod = DeliveryMethod.GMAIL_API
is_enabled: bool = True is_enabled: bool = True
check_interval_minutes: int = Field(default=5, gt=0, le=1440) check_interval_minutes: int = Field(default=5, gt=0, le=1440)
max_emails_per_check: int = Field(default=50, gt=0, le=1000) max_emails_per_check: int = Field(default=50, gt=0, le=1000)
@@ -117,6 +124,7 @@ class MailAccountUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255) name: Optional[str] = Field(None, max_length=255)
password: Optional[str] = None password: Optional[str] = None
forward_to: Optional[EmailStr] = None forward_to: Optional[EmailStr] = None
delivery_method: Optional[DeliveryMethod] = None
is_enabled: Optional[bool] = None is_enabled: Optional[bool] = None
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440) check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000) max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
@@ -127,6 +135,7 @@ class MailAccountResponse(MailAccountBase):
id: int id: int
user_id: int user_id: int
status: AccountStatus status: AccountStatus
delivery_method: DeliveryMethod
provider_name: Optional[str] = None provider_name: Optional[str] = None
auto_detected: bool auto_detected: bool
total_emails_processed: int total_emails_processed: int
@@ -137,17 +146,18 @@ class MailAccountResponse(MailAccountBase):
last_error_message: Optional[str] = None last_error_message: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
# Don't expose password or username in responses # Don't expose password or username in responses
password: str = Field(exclude=True, default="") password: str = Field(exclude=True, default="")
username: str = Field(exclude=True, default="") username: str = Field(exclude=True, default="")
class Config: class Config:
from_attributes = True from_attributes = True
class MailAccountTestRequest(BaseModel): class MailAccountTestRequest(BaseModel):
"""Test connection to mail server""" """Test connection to mail server"""
host: str host: str
port: int port: int
protocol: MailProtocol protocol: MailProtocol
@@ -165,6 +175,7 @@ class MailAccountTestResponse(BaseModel):
class MailAccountAutoDetectRequest(BaseModel): class MailAccountAutoDetectRequest(BaseModel):
"""Auto-detect mail server settings""" """Auto-detect mail server settings"""
email_address: EmailStr email_address: EmailStr
@@ -185,7 +196,7 @@ class ProcessingRunResponse(BaseModel):
emails_failed: int emails_failed: int
status: str status: str
error_message: Optional[str] = None error_message: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -199,7 +210,7 @@ class ProcessingLogResponse(BaseModel):
email_subject: Optional[str] = None email_subject: Optional[str] = None
email_from: Optional[str] = None email_from: Optional[str] = None
success: bool success: bool
class Config: class Config:
from_attributes = True from_attributes = True
@@ -231,7 +242,7 @@ class NotificationConfigResponse(NotificationConfigBase):
user_id: int user_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
@@ -250,7 +261,7 @@ class SubscriptionPlanResponse(BaseModel):
support_level: str support_level: str
features: Optional[Dict[str, Any]] = None features: Optional[Dict[str, Any]] = None
is_active: bool is_active: bool
class Config: class Config:
from_attributes = True from_attributes = True
@@ -299,6 +310,41 @@ class MailServerPresetResponse(BaseModel):
provider_domain: str provider_domain: str
configs: Dict[str, Any] configs: Dict[str, Any]
is_verified: bool is_verified: bool
class Config: class Config:
from_attributes = True from_attributes = True
# Gmail Credential Schemas
class GmailCredentialCreate(BaseModel):
access_token: str
refresh_token: Optional[str] = None
gmail_email: EmailStr
class GmailCredentialResponse(BaseModel):
id: int
user_id: int
gmail_email: str
is_valid: bool
last_verified_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Provider Wizard Schemas
class ProviderPreset(BaseModel):
id: str
name: str
icon: Optional[str] = None
domains: List[str]
imap_ssl: Optional[Dict[str, Any]] = None
pop3_ssl: Optional[Dict[str, Any]] = None
notes: Optional[str] = None
class ProviderListResponse(BaseModel):
providers: List[ProviderPreset]
+47 -42
View File
@@ -1,6 +1,7 @@
""" """
OAuth2 authentication service for Google and other providers. OAuth2 authentication service for Google and other providers.
""" """
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from datetime import datetime from datetime import datetime
import httpx import httpx
@@ -17,30 +18,32 @@ logger = logging.getLogger(__name__)
class OAuthService: class OAuthService:
"""OAuth2 authentication service""" """OAuth2 authentication service"""
def __init__(self): def __init__(self):
self.oauth = OAuth() self.oauth = OAuth()
self._register_google() self._register_google()
def _register_google(self): def _register_google(self):
"""Register Google OAuth2 provider""" """Register Google OAuth2 provider"""
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET: if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
self.oauth.register( self.oauth.register(
name='google', name="google",
client_id=settings.GOOGLE_CLIENT_ID, client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET, client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={'scope': 'openid email profile'} 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. Exchange Google authorization code for user information.
Args: Args:
code: Authorization code from Google code: Authorization code from Google
redirect_uri: Redirect URI used in OAuth flow redirect_uri: Redirect URI used in OAuth flow
Returns: Returns:
Dict with user information (email, name, google_id) Dict with user information (email, name, google_id)
""" """
@@ -48,82 +51,84 @@ class OAuthService:
# Exchange code for token # Exchange code for token
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
token_response = await client.post( token_response = await client.post(
'https://oauth2.googleapis.com/token', "https://oauth2.googleapis.com/token",
data={ data={
'code': code, "code": code,
'client_id': settings.GOOGLE_CLIENT_ID, "client_id": settings.GOOGLE_CLIENT_ID,
'client_secret': settings.GOOGLE_CLIENT_SECRET, "client_secret": settings.GOOGLE_CLIENT_SECRET,
'redirect_uri': redirect_uri, "redirect_uri": redirect_uri,
'grant_type': 'authorization_code' "grant_type": "authorization_code",
} },
) )
if token_response.status_code != 200: if token_response.status_code != 200:
logger.error(f"Google token exchange failed: {token_response.text}") logger.error(f"Google token exchange failed: {token_response.text}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code" detail="Failed to exchange authorization code",
) )
token_data = token_response.json() token_data = token_response.json()
access_token = token_data.get('access_token') access_token = token_data.get("access_token")
if not access_token: if not access_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="No access token received" detail="No access token received",
) )
# Get user info # Get user info
user_info_response = await client.get( user_info_response = await client.get(
'https://www.googleapis.com/oauth2/v2/userinfo', "https://www.googleapis.com/oauth2/v2/userinfo",
headers={'Authorization': f'Bearer {access_token}'} headers={"Authorization": f"Bearer {access_token}"},
) )
if user_info_response.status_code != 200: 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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() user_info = user_info_response.json()
return { return {
'email': user_info.get('email'), "email": user_info.get("email"),
'full_name': user_info.get('name'), "full_name": user_info.get("name"),
'google_id': user_info.get('id'), "google_id": user_info.get("id"),
'picture': user_info.get('picture'), "picture": user_info.get("picture"),
'verified_email': user_info.get('verified_email', False) "verified_email": user_info.get("verified_email", False),
} }
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"OAuth error: {e}") logger.error(f"OAuth error: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed" detail="OAuth authentication failed",
) )
@staticmethod @staticmethod
def create_tokens_for_user(user: User) -> Dict[str, str]: def create_tokens_for_user(user: User) -> Dict[str, str]:
""" """
Create access and refresh tokens for a user. Create access and refresh tokens for a user.
Args: Args:
user: User database model user: User database model
Returns: Returns:
Dict with access_token, refresh_token, and token_type Dict with access_token, refresh_token, and token_type
""" """
access_token = create_access_token(data={"sub": user.id}) access_token = create_access_token(data={"sub": user.id})
refresh_token = create_refresh_token(data={"sub": user.id}) refresh_token = create_refresh_token(data={"sub": user.id})
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
"token_type": "bearer" "token_type": "bearer",
} }
+182
View File
@@ -0,0 +1,182 @@
"""
Gmail API service for injecting emails directly into Gmail.
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
from typing import Optional, Dict, Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
GMAIL_SCOPES = [
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
]
class GmailInjectionError(Exception):
"""Raised when Gmail API injection fails"""
pass
class GmailService:
"""
Service for injecting emails into Gmail via the Gmail API.
Uses users.messages.insert() which places emails directly into
the user's mailbox without sending them through SMTP.
"""
def __init__(
self,
access_token: str,
refresh_token: Optional[str] = None,
token_uri: str = "https://oauth2.googleapis.com/token",
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
"""
Initialize Gmail service with OAuth2 credentials.
Args:
access_token: Valid OAuth2 access token
refresh_token: OAuth2 refresh token for automatic renewal
token_uri: OAuth2 token endpoint
client_id: Google OAuth2 client ID
client_secret: Google OAuth2 client secret
"""
self.credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri=token_uri,
client_id=client_id,
client_secret=client_secret,
scopes=GMAIL_SCOPES,
)
self._service = None
@property
def service(self):
"""Lazy-initialize the Gmail API service."""
if self._service is None:
self._service = build("gmail", "v1", credentials=self.credentials)
return self._service
async def inject_email(
self,
raw_email: bytes,
label_ids: Optional[list] = None,
source_account_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
Inject a raw email into the user's Gmail account.
Uses users.messages.insert() to place the email directly
into the mailbox. The email appears as if it was received
normally, preserving all original headers.
Args:
raw_email: Raw email bytes (RFC 2822 format)
label_ids: Gmail label IDs to apply (defaults to ["INBOX"])
source_account_name: Optional name for logging
Returns:
Dict with message id and thread id
Raises:
GmailInjectionError: If injection fails
"""
if label_ids is None:
label_ids = ["INBOX"]
# Base64url encode the raw email
encoded_message = base64.urlsafe_b64encode(raw_email).decode("utf-8")
message_body = {
"raw": encoded_message,
"labelIds": label_ids,
}
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users()
.messages()
.insert(userId="me", body=message_body)
.execute(),
)
logger.info(
f"Injected email into Gmail: id={result.get('id')}"
f"{f' from {source_account_name}' if source_account_name else ''}"
)
return {
"message_id": result.get("id"),
"thread_id": result.get("threadId"),
"label_ids": result.get("labelIds", []),
}
except HttpError as 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:
error_msg = f"Failed to inject email into Gmail: {str(e)}"
logger.error(error_msg)
raise GmailInjectionError(error_msg)
async def verify_access(self) -> bool:
"""
Verify that the Gmail API credentials are valid.
Returns:
True if credentials are valid and can access Gmail
"""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
email = result.get("emailAddress", "unknown")
logger.info(f"Gmail API access verified for: {email}")
return True
except Exception as e:
logger.error(f"Gmail API access verification failed: {e}")
return False
async def get_email_address(self) -> Optional[str]:
"""
Get the email address associated with the Gmail credentials.
Returns:
Email address string or None if unavailable
"""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
return result.get("emailAddress")
except Exception as e:
logger.error(f"Failed to get Gmail email address: {e}")
return None
+288 -170
View File
@@ -2,6 +2,7 @@
Mail processing service for fetching and forwarding emails. Mail processing service for fetching and forwarding emails.
Supports both POP3 and IMAP protocols with secure connections. Supports both POP3 and IMAP protocols with secure connections.
""" """
import asyncio import asyncio
import poplib import poplib
import smtplib import smtplib
@@ -23,31 +24,35 @@ logger = logging.getLogger(__name__)
class MailConnectionError(Exception): class MailConnectionError(Exception):
"""Raised when unable to connect to mail server""" """Raised when unable to connect to mail server"""
pass pass
class MailAuthenticationError(Exception): class MailAuthenticationError(Exception):
"""Raised when authentication fails""" """Raised when authentication fails"""
pass pass
class MailFetchError(Exception): class MailFetchError(Exception):
"""Raised when fetching emails fails""" """Raised when fetching emails fails"""
pass pass
class MailForwardError(Exception): class MailForwardError(Exception):
"""Raised when forwarding email fails""" """Raised when forwarding email fails"""
pass pass
class MailProcessor: class MailProcessor:
"""Handles mail fetching and forwarding operations""" """Handles mail fetching and forwarding operations"""
def __init__(self, account: MailAccount, decrypted_password: str): def __init__(self, account: MailAccount, decrypted_password: str):
self.account = account self.account = account
self.password = decrypted_password self.password = decrypted_password
async def test_connection(self) -> Tuple[bool, str]: async def test_connection(self) -> Tuple[bool, str]:
""" """
Test connection to mail server. Test connection to mail server.
@@ -61,12 +66,12 @@ class MailProcessor:
except Exception as e: except Exception as e:
logger.error(f"Connection test failed: {e}") logger.error(f"Connection test failed: {e}")
return False, str(e) return False, str(e)
async def _test_pop3_connection(self) -> Tuple[bool, str]: async def _test_pop3_connection(self) -> Tuple[bool, str]:
"""Test POP3 connection""" """Test POP3 connection"""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Run blocking POP3 operations in thread pool # Run blocking POP3 operations in thread pool
def connect_pop3(): def connect_pop3():
if self.account.protocol == MailProtocol.POP3_SSL: if self.account.protocol == MailProtocol.POP3_SSL:
@@ -75,29 +80,27 @@ class MailProcessor:
self.account.host, self.account.host,
self.account.port, self.account.port,
context=context, context=context,
timeout=10 timeout=10,
) )
else: else:
pop_conn = poplib.POP3( pop_conn = poplib.POP3(
self.account.host, self.account.host, self.account.port, timeout=10
self.account.port,
timeout=10
) )
# Try authentication # Try authentication
pop_conn.user(self.account.username) pop_conn.user(self.account.username)
pop_conn.pass_(self.password) pop_conn.pass_(self.password)
# Get mailbox stats # Get mailbox stats
message_count, mailbox_size = pop_conn.stat() message_count, mailbox_size = pop_conn.stat()
pop_conn.quit() pop_conn.quit()
return message_count, mailbox_size return message_count, mailbox_size
message_count, mailbox_size = await loop.run_in_executor(None, connect_pop3) message_count, mailbox_size = await loop.run_in_executor(None, connect_pop3)
return True, f"Connection successful. {message_count} messages in mailbox." return True, f"Connection successful. {message_count} messages in mailbox."
except poplib.error_proto as e: except poplib.error_proto as e:
error_msg = str(e) error_msg = str(e)
if "authentication" in error_msg.lower() or "auth" in error_msg.lower(): if "authentication" in error_msg.lower() or "auth" in error_msg.lower():
@@ -105,66 +108,62 @@ class MailProcessor:
return False, f"POP3 protocol error: {error_msg}" return False, f"POP3 protocol error: {error_msg}"
except Exception as e: except Exception as e:
return False, f"Connection failed: {str(e)}" return False, f"Connection failed: {str(e)}"
async def _test_imap_connection(self) -> Tuple[bool, str]: async def _test_imap_connection(self) -> Tuple[bool, str]:
"""Test IMAP connection""" """Test IMAP connection"""
try: try:
# Create IMAP client # Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL: if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL( imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host, host=self.account.host, port=self.account.port, timeout=10
port=self.account.port,
timeout=10
) )
else: else:
imap_client = aioimaplib.IMAP4( imap_client = aioimaplib.IMAP4(
host=self.account.host, host=self.account.host, port=self.account.port, timeout=10
port=self.account.port,
timeout=10
) )
await imap_client.wait_hello_from_server() await imap_client.wait_hello_from_server()
# Authenticate # Authenticate
response = await imap_client.login(self.account.username, self.password) 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}" return False, f"Authentication failed: {response.lines}"
# Select inbox # Select inbox
await imap_client.select('INBOX') await imap_client.select("INBOX")
# Get message count # Get message count
response = await imap_client.search('ALL') response = await imap_client.search("ALL")
message_ids = response.lines[0].split() message_ids = response.lines[0].split()
message_count = len(message_ids) message_count = len(message_ids)
await imap_client.logout() await imap_client.logout()
return True, f"Connection successful. {message_count} messages in mailbox." return True, f"Connection successful. {message_count} messages in mailbox."
except Exception as e: except Exception as e:
return False, f"IMAP connection failed: {str(e)}" return False, f"IMAP connection failed: {str(e)}"
async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]: async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]:
""" """
Fetch emails from the mail server. Fetch emails from the mail server.
Returns list of raw email data. Returns list of raw email data.
""" """
max_count = max_count or self.account.max_emails_per_check max_count = max_count or self.account.max_emails_per_check
if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]: if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]:
return await self._fetch_pop3_emails(max_count) return await self._fetch_pop3_emails(max_count)
else: else:
return await self._fetch_imap_emails(max_count) return await self._fetch_imap_emails(max_count)
async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]: async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via POP3""" """Fetch emails via POP3"""
emails = [] emails = []
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
def fetch_pop3(): def fetch_pop3():
# Connect # Connect
if self.account.protocol == MailProtocol.POP3_SSL: if self.account.protocol == MailProtocol.POP3_SSL:
@@ -173,37 +172,39 @@ class MailProcessor:
self.account.host, self.account.host,
self.account.port, self.account.port,
context=context, context=context,
timeout=30 timeout=30,
) )
else: else:
pop_conn = poplib.POP3( pop_conn = poplib.POP3(
self.account.host, self.account.host, self.account.port, timeout=30
self.account.port,
timeout=30
) )
# Authenticate # Authenticate
pop_conn.user(self.account.username) pop_conn.user(self.account.username)
pop_conn.pass_(self.password) pop_conn.pass_(self.password)
# Get message count # Get message count
num_messages = len(pop_conn.list()[1]) 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 = [] fetched_emails = []
messages_to_delete = [] messages_to_delete = []
# Fetch emails (limited by max_count) # Fetch emails (limited by max_count)
for i in range(1, min(num_messages + 1, max_count + 1)): for i in range(1, min(num_messages + 1, max_count + 1)):
try: try:
response, lines, octets = pop_conn.retr(i) 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) fetched_emails.append(email_data)
messages_to_delete.append(i) 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: except Exception as e:
logger.error(f"Error retrieving message {i}: {e}") logger.error(f"Error retrieving message {i}: {e}")
# Delete messages if configured # Delete messages if configured
if self.account.delete_after_forward: if self.account.delete_after_forward:
for msg_id in messages_to_delete: for msg_id in messages_to_delete:
@@ -211,100 +212,98 @@ class MailProcessor:
pop_conn.dele(msg_id) pop_conn.dele(msg_id)
except Exception as e: except Exception as e:
logger.error(f"Error deleting message {msg_id}: {e}") logger.error(f"Error deleting message {msg_id}: {e}")
pop_conn.quit() pop_conn.quit()
return fetched_emails return fetched_emails
emails = await loop.run_in_executor(None, fetch_pop3) emails = await loop.run_in_executor(None, fetch_pop3)
except Exception as e: except Exception as e:
logger.error(f"Error fetching POP3 emails: {e}") logger.error(f"Error fetching POP3 emails: {e}")
raise MailFetchError(f"POP3 fetch error: {str(e)}") raise MailFetchError(f"POP3 fetch error: {str(e)}")
return emails return emails
async def _fetch_imap_emails(self, max_count: int) -> List[bytes]: async def _fetch_imap_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via IMAP""" """Fetch emails via IMAP"""
emails = [] emails = []
try: try:
# Create IMAP client # Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL: if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL( imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host, host=self.account.host, port=self.account.port, timeout=30
port=self.account.port,
timeout=30
) )
else: else:
imap_client = aioimaplib.IMAP4( imap_client = aioimaplib.IMAP4(
host=self.account.host, host=self.account.host, port=self.account.port, timeout=30
port=self.account.port,
timeout=30
) )
await imap_client.wait_hello_from_server() await imap_client.wait_hello_from_server()
await imap_client.login(self.account.username, self.password) await imap_client.login(self.account.username, self.password)
await imap_client.select('INBOX') await imap_client.select("INBOX")
# Search for all messages # 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() message_ids = response.lines[0].split()
# Limit to max_count # Limit to max_count
message_ids = message_ids[: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 # Fetch each message
for msg_id in message_ids: for msg_id in message_ids:
try: try:
response = await imap_client.fetch(msg_id, '(RFC822)') response = await imap_client.fetch(msg_id, "(RFC822)")
# Extract email data from response # Extract email data from response
email_data = None email_data = None
for line in response.lines: 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 # Find the email content
start_idx = line.find(b'{') start_idx = line.find(b"{")
if start_idx != -1: if start_idx != -1:
# Email data is in the next parts # Email data is in the next parts
continue continue
elif isinstance(line, bytes) and not line.startswith(b'*'): elif isinstance(line, bytes) and not line.startswith(b"*"):
email_data = line email_data = line
break break
if email_data: if email_data:
emails.append(email_data) emails.append(email_data)
# Mark as seen if deleting after forward # Mark as seen if deleting after forward
if self.account.delete_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: except Exception as e:
logger.error(f"Error fetching message {msg_id}: {e}") logger.error(f"Error fetching message {msg_id}: {e}")
# Expunge deleted messages # Expunge deleted messages
if self.account.delete_after_forward: if self.account.delete_after_forward:
await imap_client.expunge() await imap_client.expunge()
await imap_client.logout() await imap_client.logout()
except Exception as e: except Exception as e:
logger.error(f"Error fetching IMAP emails: {e}") logger.error(f"Error fetching IMAP emails: {e}")
raise MailFetchError(f"IMAP fetch error: {str(e)}") raise MailFetchError(f"IMAP fetch error: {str(e)}")
return emails return emails
@staticmethod @staticmethod
async def forward_email( async def forward_email(
email_data: bytes, email_data: bytes,
source_account_name: str, source_account_name: str,
destination: str, destination: str,
smtp_config: Dict[str, Any] smtp_config: Dict[str, Any],
) -> bool: ) -> bool:
""" """
Forward an email to the destination address. Forward an email to the destination address.
Args: Args:
email_data: Raw email bytes email_data: Raw email bytes
source_account_name: Name of source account for labeling source_account_name: Name of source account for labeling
@@ -315,60 +314,68 @@ class MailProcessor:
- username: SMTP username - username: SMTP username
- password: SMTP password - password: SMTP password
- use_tls: Whether to use STARTTLS - use_tls: Whether to use STARTTLS
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
def send_email(): def send_email():
# Parse the email # Parse the email
msg = parser.BytesParser().parsebytes(email_data) msg = parser.BytesParser().parsebytes(email_data)
# Create forwarding message # Create forwarding message
forward_msg = MIMEMultipart('mixed') forward_msg = MIMEMultipart("mixed")
forward_msg['From'] = smtp_config['username'] forward_msg["From"] = smtp_config["username"]
forward_msg['To'] = destination forward_msg["To"] = destination
forward_msg['Date'] = formatdate(localtime=True) forward_msg["Date"] = formatdate(localtime=True)
forward_msg['Message-ID'] = make_msgid() forward_msg["Message-ID"] = make_msgid()
# Preserve original subject with prefix # Preserve original subject with prefix
original_subject = msg.get('Subject', 'No Subject') original_subject = msg.get("Subject", "No Subject")
forward_msg['Subject'] = f"[Fwd from {source_account_name}] {original_subject}" forward_msg["Subject"] = (
f"[Fwd from {source_account_name}] {original_subject}"
)
# Add original headers # Add original headers
header_info = f"Originally from: {msg.get('From', 'Unknown')}\n" header_info = f"Originally from: {msg.get('From', 'Unknown')}\n"
header_info += f"Original Date: {msg.get('Date', 'Unknown')}\n" header_info += f"Original Date: {msg.get('Date', 'Unknown')}\n"
header_info += f"Original Subject: {original_subject}\n" header_info += f"Original Subject: {original_subject}\n"
header_info += f"Source Account: {source_account_name}\n" header_info += f"Source Account: {source_account_name}\n"
header_info += "-" * 50 + "\n\n" header_info += "-" * 50 + "\n\n"
# Get email body # Get email body
body = "" body = ""
if msg.is_multipart(): if msg.is_multipart():
for part in msg.walk(): for part in msg.walk():
if part.get_content_type() == "text/plain": 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 break
else: else:
payload = msg.get_payload(decode=True) payload = msg.get_payload(decode=True)
if payload: if payload:
body = payload.decode('utf-8', errors='ignore') body = payload.decode("utf-8", errors="ignore")
# Combine header and body # Combine header and body
full_body = header_info + 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 # Send via SMTP
if smtp_config.get('use_tls', True): if smtp_config.get("use_tls", True):
server = smtplib.SMTP(smtp_config['host'], smtp_config['port'], timeout=30) server = smtplib.SMTP(
smtp_config["host"], smtp_config["port"], timeout=30
)
server.starttls() server.starttls()
else: 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: try:
server.login(smtp_config['username'], smtp_config['password']) server.login(smtp_config["username"], smtp_config["password"])
server.send_message(forward_msg) server.send_message(forward_msg)
logger.info(f"Successfully forwarded email to {destination}") logger.info(f"Successfully forwarded email to {destination}")
return True return True
@@ -377,9 +384,9 @@ class MailProcessor:
server.quit() server.quit()
except Exception as e: except Exception as e:
logger.warning(f"Error closing SMTP connection: {e}") logger.warning(f"Error closing SMTP connection: {e}")
return await loop.run_in_executor(None, send_email) return await loop.run_in_executor(None, send_email)
except Exception as e: except Exception as e:
logger.error(f"Error forwarding email: {e}") logger.error(f"Error forwarding email: {e}")
raise MailForwardError(f"Forward error: {str(e)}") raise MailForwardError(f"Forward error: {str(e)}")
@@ -387,7 +394,7 @@ class MailProcessor:
class MailServerAutoDetect: class MailServerAutoDetect:
"""Auto-detect mail server settings based on email domain""" """Auto-detect mail server settings based on email domain"""
# Common mail server configurations # Common mail server configurations
KNOWN_PROVIDERS = { KNOWN_PROVIDERS = {
"gmail.com": { "gmail.com": {
@@ -395,6 +402,11 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "pop.gmail.com", "port": 995}, "pop3_ssl": {"host": "pop.gmail.com", "port": 995},
"imap_ssl": {"host": "imap.gmail.com", "port": 993}, "imap_ssl": {"host": "imap.gmail.com", "port": 993},
}, },
"googlemail.com": {
"name": "Gmail",
"pop3_ssl": {"host": "pop.gmail.com", "port": 995},
"imap_ssl": {"host": "imap.gmail.com", "port": 993},
},
"outlook.com": { "outlook.com": {
"name": "Outlook.com", "name": "Outlook.com",
"pop3_ssl": {"host": "outlook.office365.com", "port": 995}, "pop3_ssl": {"host": "outlook.office365.com", "port": 995},
@@ -405,6 +417,21 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "outlook.office365.com", "port": 995}, "pop3_ssl": {"host": "outlook.office365.com", "port": 995},
"imap_ssl": {"host": "outlook.office365.com", "port": 993}, "imap_ssl": {"host": "outlook.office365.com", "port": 993},
}, },
"live.com": {
"name": "Live",
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
},
"msn.com": {
"name": "MSN",
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
},
"outlook.de": {
"name": "Outlook.de",
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
},
"gmx.com": { "gmx.com": {
"name": "GMX", "name": "GMX",
"pop3_ssl": {"host": "pop.gmx.com", "port": 995}, "pop3_ssl": {"host": "pop.gmx.com", "port": 995},
@@ -415,6 +442,21 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "pop.gmx.net", "port": 995}, "pop3_ssl": {"host": "pop.gmx.net", "port": 995},
"imap_ssl": {"host": "imap.gmx.net", "port": 993}, "imap_ssl": {"host": "imap.gmx.net", "port": 993},
}, },
"gmx.net": {
"name": "GMX",
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
},
"gmx.at": {
"name": "GMX",
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
},
"gmx.ch": {
"name": "GMX",
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
},
"web.de": { "web.de": {
"name": "WEB.DE", "name": "WEB.DE",
"pop3_ssl": {"host": "pop3.web.de", "port": 995}, "pop3_ssl": {"host": "pop3.web.de", "port": 995},
@@ -422,86 +464,162 @@ class MailServerAutoDetect:
}, },
"t-online.de": { "t-online.de": {
"name": "T-Online", "name": "T-Online",
"pop3_ssl": {"host": "pop.t-online.de", "port": 995}, "pop3_ssl": {"host": "securepop.t-online.de", "port": 995},
"imap_ssl": {"host": "imap.t-online.de", "port": 993}, "imap_ssl": {"host": "secureimap.t-online.de", "port": 993},
}, },
"yahoo.com": { "yahoo.com": {
"name": "Yahoo", "name": "Yahoo",
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995}, "pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993}, "imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
}, },
"yahoo.de": {
"name": "Yahoo",
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
},
"yahoo.co.uk": {
"name": "Yahoo",
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
},
"ymail.com": {
"name": "Yahoo",
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
},
"aol.com": {
"name": "AOL",
"pop3_ssl": {"host": "pop.aol.com", "port": 995},
"imap_ssl": {"host": "imap.aol.com", "port": 993},
},
"aim.com": {
"name": "AOL",
"pop3_ssl": {"host": "pop.aol.com", "port": 995},
"imap_ssl": {"host": "imap.aol.com", "port": 993},
},
"online.de": {
"name": "1&1 / IONOS",
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
},
"onlinehome.de": {
"name": "1&1 / IONOS",
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
},
"1und1.de": {
"name": "1&1 / IONOS",
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
},
"freenet.de": {
"name": "Freenet",
"pop3_ssl": {"host": "mx.freenet.de", "port": 995},
"imap_ssl": {"host": "mx.freenet.de", "port": 993},
},
"posteo.de": {
"name": "Posteo",
"imap_ssl": {"host": "posteo.de", "port": 993},
},
"posteo.net": {
"name": "Posteo",
"imap_ssl": {"host": "posteo.de", "port": 993},
},
"icloud.com": {
"name": "iCloud",
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
},
"me.com": {
"name": "iCloud",
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
},
"mac.com": {
"name": "iCloud",
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
},
"mail.de": {
"name": "mail.de",
"pop3_ssl": {"host": "pop.mail.de", "port": 995},
"imap_ssl": {"host": "imap.mail.de", "port": 993},
},
} }
@classmethod @classmethod
def detect(cls, email_address: str) -> List[Dict[str, Any]]: def detect(cls, email_address: str) -> List[Dict[str, Any]]:
""" """
Detect mail server settings for an email address. Detect mail server settings for an email address.
Returns list of possible configurations. Returns list of possible configurations.
""" """
domain = email_address.split('@')[-1].lower() domain = email_address.split("@")[-1].lower()
suggestions = [] suggestions = []
# Check if we have a known provider # Check if we have a known provider
if domain in cls.KNOWN_PROVIDERS: if domain in cls.KNOWN_PROVIDERS:
provider = cls.KNOWN_PROVIDERS[domain] provider = cls.KNOWN_PROVIDERS[domain]
# Add POP3 SSL suggestion # Add POP3 SSL suggestion
if "pop3_ssl" in provider: if "pop3_ssl" in provider:
suggestions.append({ suggestions.append(
"protocol": "pop3_ssl", {
"provider_name": provider["name"], "protocol": "pop3_ssl",
"host": provider["pop3_ssl"]["host"], "provider_name": provider["name"],
"port": provider["pop3_ssl"]["port"], "host": provider["pop3_ssl"]["host"],
"use_ssl": True, "port": provider["pop3_ssl"]["port"],
"use_tls": False, "use_ssl": True,
}) "use_tls": False,
}
)
# Add IMAP SSL suggestion # Add IMAP SSL suggestion
if "imap_ssl" in provider: if "imap_ssl" in provider:
suggestions.append({ suggestions.append(
"protocol": "imap_ssl", {
"provider_name": provider["name"], "protocol": "imap_ssl",
"host": provider["imap_ssl"]["host"], "provider_name": provider["name"],
"port": provider["imap_ssl"]["port"], "host": provider["imap_ssl"]["host"],
"use_ssl": True, "port": provider["imap_ssl"]["port"],
"use_tls": False, "use_ssl": True,
}) "use_tls": False,
}
)
else: else:
# Generic suggestions based on common patterns # Generic suggestions based on common patterns
suggestions.extend([ suggestions.extend(
{ [
"protocol": "pop3_ssl", {
"provider_name": "Generic", "protocol": "pop3_ssl",
"host": f"pop.{domain}", "provider_name": "Generic",
"port": 995, "host": f"pop.{domain}",
"use_ssl": True, "port": 995,
"use_tls": False, "use_ssl": True,
}, "use_tls": False,
{ },
"protocol": "pop3_ssl", {
"provider_name": "Generic", "protocol": "pop3_ssl",
"host": f"pop3.{domain}", "provider_name": "Generic",
"port": 995, "host": f"pop3.{domain}",
"use_ssl": True, "port": 995,
"use_tls": False, "use_ssl": True,
}, "use_tls": False,
{ },
"protocol": "imap_ssl", {
"provider_name": "Generic", "protocol": "imap_ssl",
"host": f"imap.{domain}", "provider_name": "Generic",
"port": 993, "host": f"imap.{domain}",
"use_ssl": True, "port": 993,
"use_tls": False, "use_ssl": True,
}, "use_tls": False,
{ },
"protocol": "imap_ssl", {
"provider_name": "Generic", "protocol": "imap_ssl",
"host": f"mail.{domain}", "provider_name": "Generic",
"port": 993, "host": f"mail.{domain}",
"use_ssl": True, "port": 993,
"use_tls": False, "use_ssl": True,
}, "use_tls": False,
]) },
]
)
return suggestions return suggestions
+2 -1
View File
@@ -1,6 +1,7 @@
""" """
Celery application for background email processing tasks. Celery application for background email processing tasks.
""" """
from celery import Celery from celery import Celery
from celery.schedules import crontab from celery.schedules import crontab
import logging import logging
@@ -14,7 +15,7 @@ celery_app = Celery(
"pop3_forwarder", "pop3_forwarder",
broker=settings.CELERY_BROKER_URL, broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND, backend=settings.CELERY_RESULT_BACKEND,
include=["app.workers.tasks"] include=["app.workers.tasks"],
) )
# Celery configuration # Celery configuration
+127 -67
View File
@@ -1,6 +1,7 @@
""" """
Celery tasks for background email processing. Celery tasks for background email processing.
""" """
import asyncio import asyncio
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -11,8 +12,17 @@ import logging
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
from app.core.database import async_session_maker from app.core.database import async_session_maker
from app.core.security import decrypt_credential from app.core.security import decrypt_credential
from app.models.database_models import MailAccount, ProcessingRun, ProcessingLog, AccountStatus from app.models.database_models import (
MailAccount,
ProcessingRun,
ProcessingLog,
AccountStatus,
DeliveryMethod,
GmailCredential,
)
from app.services.mail_processor import MailProcessor from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService, GmailInjectionError
from app.core.config import settings
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +31,7 @@ logger = logging.getLogger(__name__)
class AsyncTask(Task): class AsyncTask(Task):
"""Base task class that handles async operations""" """Base task class that handles async operations"""
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
"""Run async task in event loop""" """Run async task in event loop"""
# Use asyncio.run() for better event loop management # Use asyncio.run() for better event loop management
@@ -32,7 +42,7 @@ class AsyncTask(Task):
async def process_mail_account(account_id: int): async def process_mail_account(account_id: int):
""" """
Process a single mail account - fetch and forward emails. Process a single mail account - fetch and forward emails.
Args: Args:
account_id: ID of mail account to process account_id: ID of mail account to process
""" """
@@ -43,83 +53,127 @@ async def process_mail_account(account_id: int):
select(MailAccount).where(MailAccount.id == account_id) select(MailAccount).where(MailAccount.id == account_id)
) )
account = result.scalar_one_or_none() account = result.scalar_one_or_none()
if not account or not account.is_enabled: if not account or not account.is_enabled:
logger.warning(f"Account {account_id} not found or disabled") logger.warning(f"Account {account_id} not found or disabled")
return return
# Create processing run # Create processing run
run = ProcessingRun( run = ProcessingRun(
mail_account_id=account.id, mail_account_id=account.id,
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
status="running" status="running",
) )
db.add(run) db.add(run)
await db.commit() await db.commit()
await db.refresh(run) await db.refresh(run)
# Decrypt password # Decrypt password
password = decrypt_credential(account.encrypted_password) password = decrypt_credential(account.encrypted_password)
# Create processor # Create processor
processor = MailProcessor(account, password) processor = MailProcessor(account, password)
# Fetch emails # Fetch emails
emails = await processor.fetch_emails(account.max_emails_per_check) emails = await processor.fetch_emails(account.max_emails_per_check)
run.emails_fetched = len(emails) run.emails_fetched = len(emails)
# Forward emails # Forward emails
emails_forwarded = 0 emails_forwarded = 0
emails_failed = 0 emails_failed = 0
# Get SMTP config from environment or user settings # Determine delivery method
# TODO: Make this configurable per user in the database use_gmail_api = account.delivery_method == DeliveryMethod.GMAIL_API
smtp_config = {
"host": os.getenv("SMTP_HOST", "smtp.gmail.com"), gmail_service = None
"port": int(os.getenv("SMTP_PORT", "587")), smtp_config = None
"username": os.getenv("SMTP_USER", ""),
"password": os.getenv("SMTP_PASSWORD", ""), if use_gmail_api:
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true" # Get user's Gmail credentials
} gmail_cred_result = await db.execute(
select(GmailCredential).where(
if not smtp_config["username"] or not smtp_config["password"]: GmailCredential.user_id == account.user_id,
logger.error(f"SMTP credentials not configured for account {account.id}") GmailCredential.is_valid == True,
run.status = "failed" )
run.error_message = "SMTP credentials not configured" )
await db.commit() gmail_cred = gmail_cred_result.scalar_one_or_none()
return
if gmail_cred:
access_token = decrypt_credential(gmail_cred.encrypted_access_token)
refresh_token = (
decrypt_credential(gmail_cred.encrypted_refresh_token)
if gmail_cred.encrypted_refresh_token
else None
)
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
else:
logger.warning(
f"Gmail API credentials not found for user {account.user_id}, "
f"falling back to SMTP for account {account.id}"
)
use_gmail_api = False
if not use_gmail_api:
# Fall back to SMTP
smtp_config = {
"host": os.getenv("SMTP_HOST", "smtp.gmail.com"),
"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",
}
if not smtp_config["username"] or not smtp_config["password"]:
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()
return
for email_data in emails: for email_data in emails:
try: try:
success = await MailProcessor.forward_email( if use_gmail_api and gmail_service:
email_data, # Inject via Gmail API (preferred)
account.name, await gmail_service.inject_email(
account.forward_to, raw_email=email_data,
smtp_config label_ids=["INBOX"],
) source_account_name=account.name,
)
if success:
emails_forwarded += 1 emails_forwarded += 1
else: else:
emails_failed += 1 # Forward via SMTP (fallback)
success = await MailProcessor.forward_email(
except Exception as e: email_data, account.name, account.forward_to, smtp_config
logger.error(f"Error forwarding email: {e}") )
if success:
emails_forwarded += 1
else:
emails_failed += 1
except (GmailInjectionError, Exception) as e:
logger.error(f"Error delivering email: {e}")
emails_failed += 1 emails_failed += 1
# Update run # Update run
run.emails_forwarded = emails_forwarded run.emails_forwarded = emails_forwarded
run.emails_failed = emails_failed run.emails_failed = emails_failed
run.completed_at = datetime.utcnow() 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()
run.status = "completed" if emails_failed == 0 else "partial_failure" run.status = "completed" if emails_failed == 0 else "partial_failure"
# Update account # Update account
account.total_emails_processed += emails_forwarded account.total_emails_processed += emails_forwarded
account.total_emails_failed += emails_failed account.total_emails_failed += emails_failed
account.last_check_at = datetime.utcnow() account.last_check_at = datetime.utcnow()
if emails_failed == 0: if emails_failed == 0:
account.last_successful_check_at = datetime.utcnow() account.last_successful_check_at = datetime.utcnow()
account.status = AccountStatus.ACTIVE account.status = AccountStatus.ACTIVE
@@ -127,30 +181,32 @@ async def process_mail_account(account_id: int):
account.status = AccountStatus.ERROR account.status = AccountStatus.ERROR
account.last_error_at = datetime.utcnow() account.last_error_at = datetime.utcnow()
account.last_error_message = f"{emails_failed} emails failed to forward" account.last_error_message = f"{emails_failed} emails failed to forward"
await db.commit() await db.commit()
logger.info( logger.info(
f"Processed account {account.id}: " f"Processed account {account.id}: "
f"{emails_forwarded} forwarded, {emails_failed} failed" f"{emails_forwarded} forwarded, {emails_failed} failed"
) )
except Exception as e: except Exception as e:
logger.error(f"Error processing account {account_id}: {e}") logger.error(f"Error processing account {account_id}: {e}")
# Mark run as failed # Mark run as failed
if 'run' in locals(): if "run" in locals():
run.status = "failed" run.status = "failed"
run.error_message = str(e) run.error_message = str(e)
run.completed_at = datetime.utcnow() 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 # Update account error status
if 'account' in locals(): if "account" in locals():
account.status = AccountStatus.ERROR account.status = AccountStatus.ERROR
account.last_error_at = datetime.utcnow() account.last_error_at = datetime.utcnow()
account.last_error_message = str(e) account.last_error_message = str(e)
await db.commit() await db.commit()
@@ -167,26 +223,30 @@ async def process_all_enabled_accounts():
select(MailAccount).where( select(MailAccount).where(
and_( and_(
MailAccount.is_enabled == True, MailAccount.is_enabled == True,
MailAccount.status.in_([AccountStatus.ACTIVE, AccountStatus.TESTING]) MailAccount.status.in_(
[AccountStatus.ACTIVE, AccountStatus.TESTING]
),
) )
) )
) )
accounts = result.scalars().all() accounts = result.scalars().all()
logger.info(f"Processing {len(accounts)} enabled mail accounts") logger.info(f"Processing {len(accounts)} enabled mail accounts")
# Process each account # Process each account
for account in accounts: for account in accounts:
# Check if it's time to check this account # Check if it's time to check this account
if account.last_check_at: if account.last_check_at:
time_since_last_check = datetime.utcnow() - 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") logger.debug(f"Skipping account {account.id} - not time yet")
continue continue
# Queue processing task # Queue processing task
process_mail_account.delay(account.id) process_mail_account.delay(account.id)
except Exception as e: except Exception as e:
logger.error(f"Error processing accounts: {e}") logger.error(f"Error processing accounts: {e}")
@@ -195,38 +255,38 @@ async def process_all_enabled_accounts():
async def cleanup_old_logs(days_to_keep: int = 30): async def cleanup_old_logs(days_to_keep: int = 30):
""" """
Clean up old processing logs and runs. Clean up old processing logs and runs.
Args: Args:
days_to_keep: Number of days of logs to retain days_to_keep: Number of days of logs to retain
""" """
async with async_session_maker() as db: async with async_session_maker() as db:
try: try:
cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep) cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep)
# Delete old processing runs # Delete old processing runs
result = await db.execute( result = await db.execute(
select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date) select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date)
) )
old_runs = result.scalars().all() old_runs = result.scalars().all()
for run in old_runs: for run in old_runs:
await db.delete(run) await db.delete(run)
# Delete old processing logs # Delete old processing logs
result = await db.execute( result = await db.execute(
select(ProcessingLog).where(ProcessingLog.timestamp < cutoff_date) select(ProcessingLog).where(ProcessingLog.timestamp < cutoff_date)
) )
old_logs = result.scalars().all() old_logs = result.scalars().all()
for log in old_logs: for log in old_logs:
await db.delete(log) await db.delete(log)
await db.commit() await db.commit()
logger.info( logger.info(
f"Cleaned up {len(old_runs)} old processing runs and " f"Cleaned up {len(old_runs)} old processing runs and "
f"{len(old_logs)} old logs" f"{len(old_logs)} old logs"
) )
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up logs: {e}") logger.error(f"Error cleaning up logs: {e}")
+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))
+6 -3
View File
@@ -26,6 +26,12 @@ aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnera
aioimaplib==1.0.1 aioimaplib==1.0.1
email-validator==2.1.0.post1 email-validator==2.1.0.post1
# Gmail API (for direct email injection)
google-api-python-client==2.193.0
google-auth==2.49.1
google-auth-oauthlib==1.2.0
google-auth-httplib2==0.2.0
# Job Queue & Cache # Job Queue & Cache
celery==5.3.6 celery==5.3.6
redis==5.0.1 redis==5.0.1
@@ -50,6 +56,3 @@ faker==22.6.0
python-dotenv==1.0.0 python-dotenv==1.0.0
schedule==1.2.0 schedule==1.2.0
tenacity==8.2.3 tenacity==8.2.3
# Legacy support (for migration)
poplib3==0.0.4
+23 -16
View File
@@ -1,6 +1,7 @@
""" """
Test configuration and fixtures. Test configuration and fixtures.
""" """
import pytest import pytest
import asyncio import asyncio
from typing import AsyncGenerator, Generator 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 from app.core.security import get_password_hash, create_access_token
# Test database URL (use different database for tests) # 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 # Note: event_loop fixture removed - pytest-asyncio provides this automatically
@@ -30,18 +33,18 @@ async def db_engine():
poolclass=NullPool, poolclass=NullPool,
echo=False, echo=False,
) )
# Create tables # Create tables
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
yield engine yield engine
# Drop tables # Drop tables
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await engine.dispose() await engine.dispose()
@@ -53,7 +56,7 @@ async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
class_=AsyncSession, class_=AsyncSession,
expire_on_commit=False, expire_on_commit=False,
) )
async with async_session_maker() as session: async with async_session_maker() as session:
yield session yield session
@@ -61,15 +64,15 @@ async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""Create test client with database session override""" """Create test client with database session override"""
async def override_get_db(): async def override_get_db():
yield db_session yield db_session
app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=app, base_url="http://test") as client: async with AsyncClient(app=app, base_url="http://test") as client:
yield client yield client
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -120,9 +123,11 @@ def admin_auth_headers(test_admin_user: User) -> dict:
# Factory fixtures for creating test data # Factory fixtures for creating test data
@pytest.fixture @pytest.fixture
def user_factory(db_session: AsyncSession): def user_factory(db_session: AsyncSession):
"""Factory for creating test users""" """Factory for creating test users"""
async def _create_user( async def _create_user(
email: str = None, email: str = None,
password: str = "testpassword123", password: str = "testpassword123",
@@ -132,8 +137,9 @@ def user_factory(db_session: AsyncSession):
) -> User: ) -> User:
if email is None: if email is None:
import uuid import uuid
email = f"test-{uuid.uuid4()}@example.com" email = f"test-{uuid.uuid4()}@example.com"
user = User( user = User(
email=email, email=email,
hashed_password=get_password_hash(password), hashed_password=get_password_hash(password),
@@ -145,7 +151,7 @@ def user_factory(db_session: AsyncSession):
await db_session.commit() await db_session.commit()
await db_session.refresh(user) await db_session.refresh(user)
return user return user
return _create_user return _create_user
@@ -154,7 +160,7 @@ def mail_account_factory(db_session: AsyncSession):
"""Factory for creating test mail accounts""" """Factory for creating test mail accounts"""
from app.models.database_models import MailAccount from app.models.database_models import MailAccount
from app.core.security import encrypt_password from app.core.security import encrypt_password
async def _create_mail_account( async def _create_mail_account(
user_id: int, user_id: int,
host: str = "pop.example.com", host: str = "pop.example.com",
@@ -166,10 +172,11 @@ def mail_account_factory(db_session: AsyncSession):
) -> MailAccount: ) -> MailAccount:
if username is None: if username is None:
import uuid import uuid
username = f"test-{uuid.uuid4()}@example.com" username = f"test-{uuid.uuid4()}@example.com"
encrypted_password = encrypt_password(password, user_id) encrypted_password = encrypt_password(password, user_id)
account = MailAccount( account = MailAccount(
user_id=user_id, user_id=user_id,
host=host, host=host,
@@ -184,5 +191,5 @@ def mail_account_factory(db_session: AsyncSession):
await db_session.commit() await db_session.commit()
await db_session.refresh(account) await db_session.refresh(account)
return account return account
return _create_mail_account return _create_mail_account
+19 -12
View File
@@ -1,6 +1,7 @@
""" """
Unit tests for configuration module. Unit tests for configuration module.
""" """
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.core.config import Settings from app.core.config import Settings
@@ -8,7 +9,7 @@ from app.core.config import Settings
class TestConfigValidation: class TestConfigValidation:
"""Test configuration validation""" """Test configuration validation"""
def test_default_secret_key_rejected(self): def test_default_secret_key_rejected(self):
"""Test that default SECRET_KEY is rejected""" """Test that default SECRET_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
@@ -16,9 +17,9 @@ class TestConfigValidation:
SECRET_KEY="change-this-to-a-secure-random-secret-key-in-production", SECRET_KEY="change-this-to-a-secure-random-secret-key-in-production",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing", ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
) )
assert "SECRET_KEY must be changed from default" in str(exc_info.value) assert "SECRET_KEY must be changed from default" in str(exc_info.value)
def test_short_secret_key_rejected(self): def test_short_secret_key_rejected(self):
"""Test that short SECRET_KEY is rejected""" """Test that short SECRET_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
@@ -26,9 +27,9 @@ class TestConfigValidation:
SECRET_KEY="short", SECRET_KEY="short",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing", ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
) )
assert "at least 32 characters" in str(exc_info.value) assert "at least 32 characters" in str(exc_info.value)
def test_default_encryption_key_rejected(self): def test_default_encryption_key_rejected(self):
"""Test that default ENCRYPTION_KEY is rejected""" """Test that default ENCRYPTION_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
@@ -36,9 +37,9 @@ class TestConfigValidation:
SECRET_KEY="this-is-a-secure-32-character-key-for-testing", SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
ENCRYPTION_KEY="change-this-to-a-secure-encryption-key", ENCRYPTION_KEY="change-this-to-a-secure-encryption-key",
) )
assert "ENCRYPTION_KEY must be changed from default" in str(exc_info.value) assert "ENCRYPTION_KEY must be changed from default" in str(exc_info.value)
def test_short_encryption_key_rejected(self): def test_short_encryption_key_rejected(self):
"""Test that short ENCRYPTION_KEY is rejected""" """Test that short ENCRYPTION_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
@@ -46,15 +47,21 @@ class TestConfigValidation:
SECRET_KEY="this-is-a-secure-32-character-key-for-testing", SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
ENCRYPTION_KEY="short", ENCRYPTION_KEY="short",
) )
assert "at least 32 characters" in str(exc_info.value) assert "at least 32 characters" in str(exc_info.value)
def test_valid_keys_accepted(self): def test_valid_keys_accepted(self):
"""Test that valid keys are accepted""" """Test that valid keys are accepted"""
settings = Settings( settings = Settings(
SECRET_KEY="this-is-a-secure-32-character-key-for-testing-secret", SECRET_KEY="this-is-a-secure-32-character-key-for-testing-secret",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-encryption", 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 (
assert settings.ENCRYPTION_KEY == "this-is-a-secure-32-character-key-for-encryption" 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"
)
+155
View File
@@ -0,0 +1,155 @@
"""
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
class TestGmailService:
"""Test Gmail API service"""
def test_gmail_scopes(self):
"""Test that required Gmail scopes are defined"""
assert "https://www.googleapis.com/auth/gmail.insert" in GMAIL_SCOPES
assert "https://www.googleapis.com/auth/gmail.labels" in GMAIL_SCOPES
def test_init_creates_credentials(self):
"""Test that GmailService initializes with credentials"""
service = GmailService(
access_token="test-access-token",
refresh_token="test-refresh-token",
client_id="test-client-id",
client_secret="test-client-secret",
)
assert service.credentials is not None
assert service.credentials.token == "test-access-token"
assert service.credentials.refresh_token == "test-refresh-token"
assert service.credentials.client_id == "test-client-id"
assert service.credentials.client_secret == "test-client-secret"
def test_init_without_refresh_token(self):
"""Test initialization without refresh token"""
service = GmailService(access_token="test-access-token")
assert service.credentials is not None
assert service.credentials.token == "test-access-token"
assert service.credentials.refresh_token is None
def test_service_lazy_initialization(self):
"""Test that the API service is not created until accessed"""
service = GmailService(access_token="test-access-token")
assert service._service is None
@pytest.mark.asyncio
async def test_inject_email_success(self):
"""Test successful email injection"""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().messages().insert().execute.return_value = {
"id": "msg123",
"threadId": "thread456",
"labelIds": ["INBOX"],
}
service._service = mock_api
result = await service.inject_email(
raw_email=b"From: test@example.com\r\nSubject: Test\r\n\r\nHello",
label_ids=["INBOX"],
source_account_name="Test Account",
)
assert result["message_id"] == "msg123"
assert result["thread_id"] == "thread456"
assert "INBOX" in result["label_ids"]
@pytest.mark.asyncio
async def test_inject_email_default_labels(self):
"""Test that INBOX is used as default label"""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().messages().insert().execute.return_value = {
"id": "msg123",
"threadId": "thread456",
"labelIds": ["INBOX"],
}
service._service = mock_api
# No label_ids specified - should default to INBOX
result = await service.inject_email(
raw_email=b"From: test@example.com\r\nSubject: Test\r\n\r\nHello",
)
assert result["message_id"] == "msg123"
@pytest.mark.asyncio
async def test_inject_email_api_error(self):
"""Test that GmailInjectionError is raised on API error"""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().messages().insert().execute.side_effect = Exception(
"API Error"
)
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Failed to inject email"):
await service.inject_email(
raw_email=b"From: test@example.com\r\nSubject: Test\r\n\r\nHello",
)
@pytest.mark.asyncio
async def test_verify_access_success(self):
"""Test successful access verification"""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().getProfile().execute.return_value = {
"emailAddress": "test@gmail.com",
}
service._service = mock_api
result = await service.verify_access()
assert result is True
@pytest.mark.asyncio
async def test_verify_access_failure(self):
"""Test failed access verification"""
service = GmailService(access_token="bad-token")
mock_api = MagicMock()
mock_api.users().getProfile().execute.side_effect = Exception("Invalid token")
service._service = mock_api
result = await service.verify_access()
assert result is False
@pytest.mark.asyncio
async def test_get_email_address_success(self):
"""Test getting email address"""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().getProfile().execute.return_value = {
"emailAddress": "user@gmail.com",
}
service._service = mock_api
email = await service.get_email_address()
assert email == "user@gmail.com"
@pytest.mark.asyncio
async def test_get_email_address_failure(self):
"""Test getting email address when API fails"""
service = GmailService(access_token="bad-token")
mock_api = MagicMock()
mock_api.users().getProfile().execute.side_effect = Exception("Error")
service._service = mock_api
email = await service.get_email_address()
assert email is None
+226
View File
@@ -0,0 +1,226 @@
"""
Unit tests for provider presets and mail server auto-detection.
"""
import pytest
from app.services.mail_processor import MailServerAutoDetect
class TestMailServerAutoDetect:
"""Test mail server auto-detection with expanded provider list"""
def test_detect_gmail(self):
"""Test Gmail auto-detection"""
suggestions = MailServerAutoDetect.detect("user@gmail.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "pop.gmail.com" in hosts or "imap.gmail.com" in hosts
def test_detect_googlemail(self):
"""Test googlemail.com auto-detection"""
suggestions = MailServerAutoDetect.detect("user@googlemail.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.gmail.com" in hosts
def test_detect_gmx_de(self):
"""Test GMX.de auto-detection"""
suggestions = MailServerAutoDetect.detect("user@gmx.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.gmx.net" in hosts
def test_detect_gmx_net(self):
"""Test GMX.net auto-detection"""
suggestions = MailServerAutoDetect.detect("user@gmx.net")
assert len(suggestions) > 0
def test_detect_webde(self):
"""Test WEB.DE auto-detection"""
suggestions = MailServerAutoDetect.detect("user@web.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.web.de" in hosts
def test_detect_outlook(self):
"""Test Outlook.com auto-detection"""
suggestions = MailServerAutoDetect.detect("user@outlook.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "outlook.office365.com" in hosts
def test_detect_hotmail(self):
"""Test Hotmail auto-detection"""
suggestions = MailServerAutoDetect.detect("user@hotmail.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "outlook.office365.com" in hosts
def test_detect_yahoo(self):
"""Test Yahoo auto-detection"""
suggestions = MailServerAutoDetect.detect("user@yahoo.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.mail.yahoo.com" in hosts
def test_detect_aol(self):
"""Test AOL auto-detection"""
suggestions = MailServerAutoDetect.detect("user@aol.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.aol.com" in hosts
def test_detect_tonline(self):
"""Test T-Online auto-detection"""
suggestions = MailServerAutoDetect.detect("user@t-online.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "secureimap.t-online.de" in hosts
def test_detect_ionos(self):
"""Test 1&1/IONOS auto-detection"""
suggestions = MailServerAutoDetect.detect("user@online.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.ionos.de" in hosts
def test_detect_freenet(self):
"""Test Freenet auto-detection"""
suggestions = MailServerAutoDetect.detect("user@freenet.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "mx.freenet.de" in hosts
def test_detect_posteo(self):
"""Test Posteo auto-detection (IMAP only)"""
suggestions = MailServerAutoDetect.detect("user@posteo.de")
assert len(suggestions) > 0
# Posteo only has IMAP
protocols = [s["protocol"] for s in suggestions]
assert "imap_ssl" in protocols
def test_detect_icloud(self):
"""Test iCloud auto-detection"""
suggestions = MailServerAutoDetect.detect("user@icloud.com")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.mail.me.com" in hosts
def test_detect_unknown_domain(self):
"""Test auto-detection for unknown domain"""
suggestions = MailServerAutoDetect.detect("user@unknowndomain123.com")
assert len(suggestions) > 0
# Should return generic suggestions
providers = set(s["provider_name"] for s in suggestions)
assert "Generic" in providers
def test_detect_case_insensitive(self):
"""Test that domain detection is case-insensitive"""
suggestions_lower = MailServerAutoDetect.detect("user@Gmail.com")
suggestions_upper = MailServerAutoDetect.detect("user@GMAIL.COM")
# Both should detect as Gmail
assert len(suggestions_lower) > 0
assert len(suggestions_upper) > 0
def test_all_suggestions_have_required_fields(self):
"""Test that all suggestions have the required fields"""
for domain in ["gmail.com", "gmx.de", "web.de", "yahoo.com", "aol.com"]:
suggestions = MailServerAutoDetect.detect(f"user@{domain}")
for suggestion in suggestions:
assert "protocol" in suggestion
assert "host" in suggestion
assert "port" in suggestion
assert "provider_name" in suggestion
assert "use_ssl" in suggestion
def test_detect_live_com(self):
"""Test Live.com auto-detection (Microsoft)"""
suggestions = MailServerAutoDetect.detect("user@live.com")
assert len(suggestions) > 0
def test_detect_ymail(self):
"""Test ymail.com auto-detection (Yahoo)"""
suggestions = MailServerAutoDetect.detect("user@ymail.com")
assert len(suggestions) > 0
def test_detect_mailde(self):
"""Test mail.de auto-detection"""
suggestions = MailServerAutoDetect.detect("user@mail.de")
assert len(suggestions) > 0
hosts = [s["host"] for s in suggestions]
assert "imap.mail.de" in hosts
class TestProviderPresets:
"""Test that provider presets module defines correct values"""
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
assert len(preset.domains) > 0
# Must have at least one protocol
assert preset.imap_ssl is not None or preset.pop3_ssl is not None
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
assert gmail.imap_ssl["host"] == "imap.gmail.com"
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
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
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
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
+41 -33
View File
@@ -1,103 +1,111 @@
""" """
Unit tests for security module. Unit tests for security module.
""" """
import pytest import pytest
from app.core.security import ( from app.core.security import (
get_password_hash, get_password_hash,
verify_password, verify_password,
create_access_token, create_access_token,
encrypt_password, CredentialEncryption,
decrypt_password,
) )
class TestPasswordHashing: class TestPasswordHashing:
"""Test password hashing and verification""" """Test password hashing and verification"""
def test_hash_password(self): def test_hash_password(self):
"""Test password hashing""" """Test password hashing"""
password = "securepassword123" password = "securepassword123"
hashed = get_password_hash(password) hashed = get_password_hash(password)
assert hashed != password assert hashed != password
assert len(hashed) > 50 assert len(hashed) > 50
assert hashed.startswith("$2b$") assert hashed.startswith("$2b$")
def test_verify_password_success(self): def test_verify_password_success(self):
"""Test password verification with correct password""" """Test password verification with correct password"""
password = "securepassword123" password = "securepassword123"
hashed = get_password_hash(password) hashed = get_password_hash(password)
assert verify_password(password, hashed) is True assert verify_password(password, hashed) is True
def test_verify_password_failure(self): def test_verify_password_failure(self):
"""Test password verification with wrong password""" """Test password verification with wrong password"""
password = "securepassword123" password = "securepassword123"
wrong_password = "wrongpassword" wrong_password = "wrongpassword"
hashed = get_password_hash(password) hashed = get_password_hash(password)
assert verify_password(wrong_password, hashed) is False assert verify_password(wrong_password, hashed) is False
class TestJWT: class TestJWT:
"""Test JWT token creation and validation""" """Test JWT token creation and validation"""
def test_create_access_token(self): def test_create_access_token(self):
"""Test access token creation""" """Test access token creation"""
data = {"sub": "test@example.com"} data = {"sub": "test@example.com"}
token = create_access_token(data) token = create_access_token(data)
assert isinstance(token, str) assert isinstance(token, str)
assert len(token) > 50 assert len(token) > 50
assert token.count('.') == 2 # JWT has 3 parts assert token.count(".") == 2 # JWT has 3 parts
class TestEncryption: class TestEncryption:
"""Test credential encryption/decryption""" """Test credential encryption/decryption"""
def test_encrypt_password(self): def test_encrypt_password(self):
"""Test password encryption""" """Test password encryption"""
password = "mailpassword123" password = "mailpassword123"
user_id = 1 user_id = 1
encrypted = encrypt_password(password, user_id) encryptor = CredentialEncryption(user_id=user_id)
encrypted = encryptor.encrypt(password)
assert encrypted != password assert encrypted != password
assert len(encrypted) > 50 assert len(encrypted) > 50
def test_decrypt_password(self): def test_decrypt_password(self):
"""Test password decryption""" """Test password decryption"""
password = "mailpassword123" password = "mailpassword123"
user_id = 1 user_id = 1
encrypted = encrypt_password(password, user_id) encryptor = CredentialEncryption(user_id=user_id)
decrypted = decrypt_password(encrypted, user_id) encrypted = encryptor.encrypt(password)
decrypted = encryptor.decrypt(encrypted)
assert decrypted == password assert decrypted == password
def test_encryption_with_different_user_ids(self): def test_encryption_with_different_user_ids(self):
"""Test that encryption produces different results for different users""" """Test that encryption produces different results for different users"""
password = "mailpassword123" password = "mailpassword123"
user_id_1 = 1 user_id_1 = 1
user_id_2 = 2 user_id_2 = 2
encrypted_1 = encrypt_password(password, user_id_1) encryptor_1 = CredentialEncryption(user_id=user_id_1)
encrypted_2 = encrypt_password(password, user_id_2) encryptor_2 = CredentialEncryption(user_id=user_id_2)
encrypted_1 = encryptor_1.encrypt(password)
encrypted_2 = encryptor_2.encrypt(password)
# Different users should produce different encrypted values # Different users should produce different encrypted values
assert encrypted_1 != encrypted_2 assert encrypted_1 != encrypted_2
# But decryption should work correctly for each # But decryption should work correctly for each
assert decrypt_password(encrypted_1, user_id_1) == password assert encryptor_1.decrypt(encrypted_1) == password
assert decrypt_password(encrypted_2, user_id_2) == password assert encryptor_2.decrypt(encrypted_2) == password
def test_decrypt_with_wrong_user_id_fails(self): def test_decrypt_with_wrong_user_id_fails(self):
"""Test that decryption fails with wrong user ID""" """Test that decryption fails with wrong user ID"""
password = "mailpassword123" password = "mailpassword123"
user_id = 1 user_id = 1
wrong_user_id = 2 wrong_user_id = 2
encrypted = encrypt_password(password, user_id) encryptor = CredentialEncryption(user_id=user_id)
wrong_encryptor = CredentialEncryption(user_id=wrong_user_id)
encrypted = encryptor.encrypt(password)
with pytest.raises(Exception): with pytest.raises(Exception):
decrypt_password(encrypted, wrong_user_id) wrong_encryptor.decrypt(encrypted)
+206 -161
View File
@@ -4,17 +4,21 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api'; import { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api';
import { X, Loader2, CheckCircle, XCircle } from 'lucide-react'; import { X, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { ProviderWizard } from './ProviderWizard';
interface AddMailAccountModalProps { interface AddMailAccountModalProps {
account?: MailAccount | null; account?: MailAccount | null;
onClose: () => void; onClose: () => void;
} }
type WizardStep = 'provider' | 'form';
export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) { export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState(''); const [testMessage, setTestMessage] = useState('');
const [autoDetecting, setAutoDetecting] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false);
const [wizardStep, setWizardStep] = useState<WizardStep>(account ? 'form' : 'provider');
const [formData, setFormData] = useState<MailAccountCreate>({ const [formData, setFormData] = useState<MailAccountCreate>({
name: account?.name || '', name: account?.name || '',
@@ -46,6 +50,18 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
})); }));
}; };
const handleProviderSelect = (config: { name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
setFormData((prev) => ({
...prev,
name: config.name,
protocol: config.protocol,
host: config.host,
port: config.port,
use_ssl: config.use_ssl,
}));
setWizardStep('form');
};
const handleAutoDetect = async () => { const handleAutoDetect = async () => {
if (!formData.username) { if (!formData.username) {
alert('Please enter an email address first'); alert('Please enter an email address first');
@@ -131,208 +147,237 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button> </button>
</div> </div>
<div className="space-y-4"> {wizardStep === 'provider' && !account ? (
<div> <ProviderWizard
<label className="block text-sm font-medium text-gray-700 mb-1"> onSelect={handleProviderSelect}
Account Name onManual={() => setWizardStep('form')}
</label> />
<input ) : (
type="text" <div className="space-y-4">
name="name" {!account && (
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My Email Account"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address / Username
</label>
<div className="flex gap-2">
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
required
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="user@example.com"
/>
<button <button
type="button" type="button"
onClick={handleAutoDetect} onClick={() => setWizardStep('provider')}
disabled={autoDetecting} className="text-sm text-blue-600 hover:text-blue-800 mb-2"
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
> >
{autoDetecting ? 'Detecting...' : 'Auto-Detect'} Back to provider selection
</button> </button>
</div> )}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required={!account}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={account ? 'Leave blank to keep current password' : 'Password'}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Protocol
</label>
<select
name="protocol"
value={formData.protocol}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="pop3">POP3</option>
<option value="imap">IMAP</option>
</select>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Host Account Name
</label> </label>
<input <input
type="text" type="text"
name="host" name="name"
value={formData.host} value={formData.name}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="pop.gmail.com" placeholder="My Email Account"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Port Email Address / Username
</label> </label>
<input <div className="flex gap-2">
type="number" <input
name="port" type="text"
value={formData.port} name="username"
onChange={handleChange} value={formData.username}
required onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required
/> className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
</div> placeholder="user@example.com"
</div> />
<button
<div className="flex items-center"> type="button"
<input onClick={handleAutoDetect}
type="checkbox" disabled={autoDetecting}
name="use_ssl" className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
id="use_ssl" >
checked={formData.use_ssl} {autoDetecting ? 'Detecting...' : 'Auto-Detect'}
onChange={handleChange} </button>
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" </div>
/>
<label htmlFor="use_ssl" className="ml-2 block text-sm text-gray-700">
Use SSL/TLS
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Check Interval (minutes)
</label>
<input
type="number"
name="check_interval_minutes"
value={formData.check_interval_minutes}
onChange={handleChange}
required
min="1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Max Emails Per Check Password
</label> </label>
<input <input
type="number" type="password"
name="max_emails_per_check" name="password"
value={formData.max_emails_per_check} value={formData.password}
onChange={handleChange} onChange={handleChange}
min="1" required={!account}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={account ? 'Leave blank to keep current password' : 'Password'}
/> />
</div> </div>
</div>
{testStatus !== 'idle' && ( <div className="grid grid-cols-3 gap-4">
<div <div>
className={`p-3 rounded-md flex items-start ${ <label className="block text-sm font-medium text-gray-700 mb-1">
testStatus === 'success' Protocol
? 'bg-green-50 border border-green-200' </label>
: testStatus === 'error' <select
? 'bg-red-50 border border-red-200' name="protocol"
: 'bg-blue-50 border border-blue-200' value={formData.protocol}
}`} onChange={handleChange}
> className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
{testStatus === 'testing' && <Loader2 className="h-5 w-5 text-blue-500 animate-spin mr-2" />} >
{testStatus === 'success' && <CheckCircle className="h-5 w-5 text-green-500 mr-2" />} <option value="pop3">POP3</option>
{testStatus === 'error' && <XCircle className="h-5 w-5 text-red-500 mr-2" />} <option value="pop3_ssl">POP3 (SSL)</option>
<span <option value="imap">IMAP</option>
className={`text-sm ${ <option value="imap_ssl">IMAP (SSL)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Host
</label>
<input
type="text"
name="host"
value={formData.host}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="pop.gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port
</label>
<input
type="number"
name="port"
value={formData.port}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="use_ssl"
id="use_ssl"
checked={formData.use_ssl}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="use_ssl" className="ml-2 block text-sm text-gray-700">
Use SSL/TLS
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Check Interval (minutes)
</label>
<input
type="number"
name="check_interval_minutes"
value={formData.check_interval_minutes}
onChange={handleChange}
required
min="1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Emails Per Check
</label>
<input
type="number"
name="max_emails_per_check"
value={formData.max_emails_per_check}
onChange={handleChange}
min="1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Delivery:</strong> Emails will be delivered to your Gmail account.
Configure your Gmail API credentials in Settings for direct injection (recommended),
or they will be forwarded via SMTP.
</p>
</div>
{testStatus !== 'idle' && (
<div
className={`p-3 rounded-md flex items-start ${
testStatus === 'success' testStatus === 'success'
? 'text-green-700' ? 'bg-green-50 border border-green-200'
: testStatus === 'error' : testStatus === 'error'
? 'text-red-700' ? 'bg-red-50 border border-red-200'
: 'text-blue-700' : 'bg-blue-50 border border-blue-200'
}`} }`}
> >
{testStatus === 'testing' ? 'Testing connection...' : testMessage} {testStatus === 'testing' && <Loader2 className="h-5 w-5 text-blue-500 animate-spin mr-2" />}
</span> {testStatus === 'success' && <CheckCircle className="h-5 w-5 text-green-500 mr-2" />}
</div> {testStatus === 'error' && <XCircle className="h-5 w-5 text-red-500 mr-2" />}
)} <span
</div> className={`text-sm ${
testStatus === 'success'
? 'text-green-700'
: testStatus === 'error'
? 'text-red-700'
: 'text-blue-700'
}`}
>
{testStatus === 'testing' ? 'Testing connection...' : testMessage}
</span>
</div>
)}
</div>
)}
</div> </div>
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between gap-3"> {wizardStep === 'form' && (
<button <div className="bg-gray-50 px-6 py-4 flex items-center justify-between gap-3">
type="button"
onClick={handleTestConnection}
disabled={testStatus === 'testing'}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Test Connection
</button>
<div className="flex gap-3">
<button <button
type="button" type="button"
onClick={onClose} onClick={handleTestConnection}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50" disabled={testStatus === 'testing'}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 disabled:opacity-50"
> >
Cancel Test Connection
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Saving...' : 'Save'}
</button> </button>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div> </div>
</div> )}
</form> </form>
</div> </div>
</div> </div>
+279
View File
@@ -0,0 +1,279 @@
'use client';
import { useState } from 'react';
import { ChevronRight, Mail, ArrowLeft } from 'lucide-react';
interface ProviderPreset {
id: string;
name: string;
icon: string;
domains: string[];
imap_ssl?: { host: string; port: number } | null;
pop3_ssl?: { host: string; port: number } | null;
notes?: string;
}
interface ProviderConfig {
name: string;
protocol: string;
host: string;
port: number;
use_ssl: boolean;
}
interface ProviderWizardProps {
onSelect: (config: ProviderConfig) => void;
onManual: () => void;
}
const PROVIDERS: ProviderPreset[] = [
{
id: 'gmail',
name: 'Gmail',
icon: '📧',
domains: ['gmail.com', 'googlemail.com'],
imap_ssl: { host: 'imap.gmail.com', port: 993 },
pop3_ssl: { host: 'pop.gmail.com', port: 995 },
notes: 'Enable IMAP/POP3 in Gmail settings. Use an App Password if 2FA is enabled.',
},
{
id: 'gmx',
name: 'GMX',
icon: '📮',
domains: ['gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com'],
imap_ssl: { host: 'imap.gmx.net', port: 993 },
pop3_ssl: { host: 'pop.gmx.net', port: 995 },
notes: 'Enable POP3/IMAP in GMX settings under E-Mail > POP3/IMAP Abruf.',
},
{
id: 'webde',
name: 'WEB.DE',
icon: '📬',
domains: ['web.de'],
imap_ssl: { host: 'imap.web.de', port: 993 },
pop3_ssl: { host: 'pop3.web.de', port: 995 },
notes: 'Enable POP3/IMAP in WEB.DE settings under E-Mail > POP3/IMAP Abruf.',
},
{
id: 'outlook',
name: 'Outlook / Hotmail',
icon: '📨',
domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com', 'outlook.de'],
imap_ssl: { host: 'outlook.office365.com', port: 993 },
pop3_ssl: { host: 'outlook.office365.com', port: 995 },
notes: 'Use your Microsoft account credentials.',
},
{
id: 'yahoo',
name: 'Yahoo Mail',
icon: '💌',
domains: ['yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'ymail.com'],
imap_ssl: { host: 'imap.mail.yahoo.com', port: 993 },
pop3_ssl: { host: 'pop.mail.yahoo.com', port: 995 },
notes: 'Generate an App Password in Yahoo account security settings.',
},
{
id: 'aol',
name: 'AOL Mail',
icon: '📪',
domains: ['aol.com', 'aim.com'],
imap_ssl: { host: 'imap.aol.com', port: 993 },
pop3_ssl: { host: 'pop.aol.com', port: 995 },
notes: 'Generate an App Password in AOL account security settings.',
},
{
id: 'tonline',
name: 'T-Online',
icon: '🇩🇪',
domains: ['t-online.de'],
imap_ssl: { host: 'secureimap.t-online.de', port: 993 },
pop3_ssl: { host: 'securepop.t-online.de', port: 995 },
notes: 'Use your T-Online E-Mail-Passwort (not your Telekom login password).',
},
{
id: 'ionos',
name: '1&1 / IONOS',
icon: '🌐',
domains: ['online.de', 'onlinehome.de', '1und1.de'],
imap_ssl: { host: 'imap.ionos.de', port: 993 },
pop3_ssl: { host: 'pop.ionos.de', port: 995 },
notes: 'Use your IONOS email credentials.',
},
{
id: 'freenet',
name: 'Freenet',
icon: '📫',
domains: ['freenet.de'],
imap_ssl: { host: 'mx.freenet.de', port: 993 },
pop3_ssl: { host: 'mx.freenet.de', port: 995 },
notes: 'Use your Freenet email credentials.',
},
{
id: 'icloud',
name: 'iCloud Mail',
icon: '☁️',
domains: ['icloud.com', 'me.com', 'mac.com'],
imap_ssl: { host: 'imap.mail.me.com', port: 993 },
pop3_ssl: null,
notes: 'Generate an app-specific password at appleid.apple.com. IMAP only.',
},
{
id: 'posteo',
name: 'Posteo',
icon: '🌿',
domains: ['posteo.de', 'posteo.net'],
imap_ssl: { host: 'posteo.de', port: 993 },
pop3_ssl: null,
notes: 'Posteo supports IMAP only.',
},
];
export function ProviderWizard({ onSelect, onManual }: ProviderWizardProps) {
const [selectedProvider, setSelectedProvider] = useState<ProviderPreset | null>(null);
const [selectedProtocol, setSelectedProtocol] = useState<'imap_ssl' | 'pop3_ssl'>('imap_ssl');
const handleProviderClick = (provider: ProviderPreset) => {
setSelectedProvider(provider);
// If only one protocol, auto-select it
if (!provider.pop3_ssl && provider.imap_ssl) {
setSelectedProtocol('imap_ssl');
} else if (provider.pop3_ssl && !provider.imap_ssl) {
setSelectedProtocol('pop3_ssl');
}
};
const handleConfirm = () => {
if (!selectedProvider) return;
const config = selectedProvider[selectedProtocol];
if (!config) return;
onSelect({
name: selectedProvider.name,
protocol: selectedProtocol === 'imap_ssl' ? 'imap_ssl' : 'pop3_ssl',
host: config.host,
port: config.port,
use_ssl: true,
});
};
if (selectedProvider) {
return (
<div className="space-y-4">
<button
type="button"
onClick={() => setSelectedProvider(null)}
className="flex items-center text-sm text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to providers
</button>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2">
{selectedProvider.icon} {selectedProvider.name}
</h4>
<p className="text-sm text-blue-700 mb-1">
Domains: {selectedProvider.domains.join(', ')}
</p>
{selectedProvider.notes && (
<p className="text-sm text-blue-600 mt-2 italic">{selectedProvider.notes}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Protocol
</label>
<div className="grid grid-cols-2 gap-3">
{selectedProvider.imap_ssl && (
<button
type="button"
onClick={() => setSelectedProtocol('imap_ssl')}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
selectedProtocol === 'imap_ssl'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">IMAP (Recommended)</div>
<div className="text-xs text-gray-500 mt-1">
{selectedProvider.imap_ssl.host}:{selectedProvider.imap_ssl.port}
</div>
</button>
)}
{selectedProvider.pop3_ssl && (
<button
type="button"
onClick={() => setSelectedProtocol('pop3_ssl')}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
selectedProtocol === 'pop3_ssl'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">POP3</div>
<div className="text-xs text-gray-500 mt-1">
{selectedProvider.pop3_ssl.host}:{selectedProvider.pop3_ssl.port}
</div>
</button>
)}
</div>
</div>
<button
type="button"
onClick={handleConfirm}
className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Use {selectedProvider.name} Settings
</button>
</div>
);
}
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Quick Setup Select Your Email Provider
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{PROVIDERS.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleProviderClick(provider)}
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors text-left"
>
<span className="text-xl">{provider.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{provider.name}</div>
</div>
<ChevronRight className="h-4 w-4 text-gray-400 flex-shrink-0" />
</button>
))}
</div>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">or</span>
</div>
</div>
<button
type="button"
onClick={onManual}
className="w-full flex items-center justify-center gap-2 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition-colors"
>
<Mail className="h-4 w-4" />
Configure Manually
</button>
</div>
);
}