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"""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
@@ -49,10 +50,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
+22 -4
View File
@@ -1,16 +1,34 @@
"""
API v1 router aggregation.
"""
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()
# Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(
mail_accounts.router, prefix="/mail-accounts", tags=["Mail Accounts"]
)
api_router.include_router(
providers.router, prefix="/providers", tags=["Providers & Gmail"]
)
api_router.include_router(
notifications.router, prefix="/notifications", tags=["Notifications"]
)
api_router.include_router(
subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"]
)
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
+7 -6
View File
@@ -1,4 +1,5 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -13,24 +14,24 @@ router = APIRouter()
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get overall system statistics (admin only)"""
# Count users
user_count = await db.execute(select(func.count(User.id)))
total_users = user_count.scalar()
# Count accounts
account_count = await db.execute(select(func.count(MailAccount.id)))
total_accounts = account_count.scalar()
# Count processing runs
run_count = await db.execute(select(func.count(ProcessingRun.id)))
total_runs = run_count.scalar()
return {
"total_users": total_users,
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs
"total_processing_runs": total_runs,
}
+51 -62
View File
@@ -1,6 +1,7 @@
"""
Authentication endpoints (login, register, OAuth).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,72 +12,65 @@ import logging
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash
from app.models.database_models import User, SubscriptionTier
from app.models.schemas import (
Token, UserCreate, UserResponse, GoogleAuthRequest
)
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_in: UserCreate,
db: AsyncSession = Depends(get_db)
):
@router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user with email and password"""
# Check if user exists
result = await db.execute(
select(User).where(User.email == user_in.email)
)
result = await db.execute(select(User).where(User.email == user_in.email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)
# Create new user
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password) if user_in.password else None,
hashed_password=(
get_password_hash(user_in.password) if user_in.password else None
),
subscription_tier=SubscriptionTier.FREE,
is_active=True
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"New user registered: {user.email}")
return user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
):
"""Login with email and password"""
# Get user
result = await db.execute(
select(User).where(User.email == form_data.username)
)
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify password
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
@@ -84,90 +78,85 @@ async def login(
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
# Update last login
user.last_login_at = datetime.utcnow()
await db.commit()
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
logger.info(f"User logged in: {user.email}")
return tokens
@router.post("/google", response_model=Token)
async def google_oauth(
auth_request: GoogleAuthRequest,
db: AsyncSession = Depends(get_db)
auth_request: GoogleAuthRequest, db: AsyncSession = Depends(get_db)
):
"""
Authenticate with Google OAuth2.
Exchange authorization code for access token and user info.
"""
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code,
redirect_uri=auth_request.redirect_uri
code=auth_request.code, redirect_uri=auth_request.redirect_uri
)
if not user_info.get('verified_email'):
if not user_info.get("verified_email"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google"
detail="Email not verified with Google",
)
email = user_info['email']
google_id = user_info['google_id']
email = user_info["email"]
google_id = user_info["google_id"]
# Check if user exists
result = await db.execute(
select(User).where(
(User.email == email) | (User.google_id == google_id)
)
select(User).where((User.email == email) | (User.google_id == google_id))
)
user = result.scalar_one_or_none()
if user:
# Update Google ID if not set
if not user.google_id:
user.google_id = google_id
user.oauth_provider = "google"
# Update last login
user.last_login_at = datetime.utcnow()
logger.info(f"Existing user logged in with Google: {user.email}")
else:
# Create new user
user = User(
email=email,
full_name=user_info.get('full_name'),
full_name=user_info.get("full_name"),
google_id=google_id,
oauth_provider="google",
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.utcnow()
last_login_at=datetime.utcnow(),
)
db.add(user)
logger.info(f"New user registered with Google: {user.email}")
await db.commit()
await db.refresh(user)
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
return tokens
@@ -175,7 +164,7 @@ async def google_oauth(
async def get_google_authorize_url(redirect_uri: str):
"""Get Google OAuth2 authorization URL"""
from app.core.config import settings
auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={settings.GOOGLE_CLIENT_ID}&"
@@ -184,5 +173,5 @@ async def get_google_authorize_url(redirect_uri: str):
f"redirect_uri={redirect_uri}&"
f"access_type=offline"
)
return {"authorization_url": auth_url}
+54 -54
View File
@@ -1,4 +1,5 @@
"""Mail account management endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,9 +10,13 @@ from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential, decrypt_credential
from app.models.database_models import User, MailAccount
from app.models.schemas import (
MailAccountCreate, MailAccountResponse, MailAccountUpdate,
MailAccountTestRequest, MailAccountTestResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectResponse
MailAccountCreate,
MailAccountResponse,
MailAccountUpdate,
MailAccountTestRequest,
MailAccountTestResponse,
MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse,
)
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings
@@ -19,38 +24,40 @@ from app.core.config import settings
router = APIRouter()
@router.post("", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED
)
async def create_mail_account(
account_in: MailAccountCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create a new mail account"""
# Check subscription limits
result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id)
)
existing_accounts = result.scalars().all()
tier_limits = {
"free": settings.TIER_FREE_MAX_ACCOUNTS,
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
}
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
if len(existing_accounts) >= max_accounts:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Account limit reached. Upgrade your subscription to add more accounts."
detail=f"Account limit reached. Upgrade your subscription to add more accounts.",
)
# Encrypt password
encrypted_password = encrypt_credential(account_in.password)
# Create account
account = MailAccount(
user_id=current_user.id,
@@ -64,23 +71,24 @@ async def create_mail_account(
username=account_in.username,
encrypted_password=encrypted_password,
forward_to=account_in.forward_to,
delivery_method=account_in.delivery_method,
is_enabled=account_in.is_enabled,
check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check,
delete_after_forward=account_in.delete_after_forward
delete_after_forward=account_in.delete_after_forward,
)
db.add(account)
await db.commit()
await db.refresh(account)
return account
@router.get("", response_model=List[MailAccountResponse])
async def list_mail_accounts(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all mail accounts for current user"""
result = await db.execute(
@@ -96,23 +104,21 @@ async def list_mail_accounts(
async def get_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get a specific mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
return account
@@ -121,35 +127,35 @@ async def update_mail_account(
account_id: int,
account_update: MailAccountUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
# Update fields
update_data = account_update.dict(exclude_unset=True)
if "password" in update_data:
update_data["encrypted_password"] = encrypt_credential(update_data.pop("password"))
update_data["encrypted_password"] = encrypt_credential(
update_data.pop("password")
)
for field, value in update_data.items():
setattr(account, field, value)
await db.commit()
await db.refresh(account)
return account
@@ -157,23 +163,21 @@ async def update_mail_account(
async def delete_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Delete a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
await db.delete(account)
await db.commit()
@@ -184,7 +188,7 @@ async def test_mail_connection(
current_user: User = Depends(get_current_active_user),
):
"""Test connection to mail server"""
# Create temporary account for testing
temp_account = MailAccount(
user_id=current_user.id,
@@ -197,16 +201,13 @@ async def test_mail_connection(
use_tls=test_request.use_tls,
username=test_request.username,
encrypted_password="", # Not used for test
forward_to="test@test.com"
forward_to="test@test.com",
)
processor = MailProcessor(temp_account, test_request.password)
success, message = await processor.test_connection()
return MailAccountTestResponse(
success=success,
message=message
)
return MailAccountTestResponse(success=success, message=message)
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
@@ -215,10 +216,9 @@ async def auto_detect_mail_settings(
current_user: User = Depends(get_current_active_user),
):
"""Auto-detect mail server settings for an email address"""
suggestions = MailServerAutoDetect.detect(detect_request.email_address)
return MailAccountAutoDetectResponse(
success=len(suggestions) > 0,
suggestions=suggestions
success=len(suggestions) > 0, suggestions=suggestions
)
+10 -8
View File
@@ -1,4 +1,5 @@
"""Notification configuration endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,23 +9,24 @@ from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig
from app.models.schemas import (
NotificationConfigCreate, NotificationConfigResponse, NotificationConfigUpdate
NotificationConfigCreate,
NotificationConfigResponse,
NotificationConfigUpdate,
)
router = APIRouter()
@router.post("", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED
)
async def create_notification_config(
config_in: NotificationConfigCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create notification configuration"""
config = NotificationConfig(
user_id=current_user.id,
**config_in.dict()
)
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
db.add(config)
await db.commit()
await db.refresh(config)
@@ -34,7 +36,7 @@ async def create_notification_config(
@router.get("", response_model=List[NotificationConfigResponse])
async def list_notification_configs(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all notification configurations"""
result = await db.execute(
+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"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +14,7 @@ router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans(
db: AsyncSession = Depends(get_db)
):
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
"""List all available subscription plans"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True)
@@ -25,11 +24,11 @@ async def list_subscription_plans(
@router.get("/current")
async def get_current_subscription(
current_user: User = Depends(get_current_active_user)
current_user: User = Depends(get_current_active_user),
):
"""Get current user's subscription details"""
return {
"tier": current_user.subscription_tier,
"status": current_user.subscription_status,
"expires_at": current_user.subscription_expires_at
"expires_at": current_user.subscription_expires_at,
}
+4 -3
View File
@@ -1,4 +1,5 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,7 @@ router = APIRouter()
@router.get("/me", response_model=UserDetailResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
current_user: User = Depends(get_current_active_user),
):
"""Get current user profile"""
return current_user
@@ -22,14 +23,14 @@ async def get_current_user_profile(
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update current user profile"""
if user_update.email:
current_user.email = user_update.email
if user_update.full_name:
current_user.full_name = user_update.full_name
await db.commit()
await db.refresh(current_user)
return current_user
+29 -25
View File
@@ -2,6 +2,7 @@
Application configuration using Pydantic settings.
Supports environment variables and .env files.
"""
from typing import Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import PostgresDsn, field_validator, ValidationInfo
@@ -9,82 +10,85 @@ from pydantic import PostgresDsn, field_validator, ValidationInfo
class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
# Application
APP_NAME: str = "POP3 Forwarder SaaS"
APP_VERSION: str = "2.0.0"
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
# Server
HOST: str = "0.0.0.0"
PORT: int = 8000
# Database
DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
DATABASE_URL: str = (
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
)
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
# Security
SECRET_KEY: str = "change-this-to-a-secure-random-secret-key-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Encryption (for storing POP3/IMAP credentials)
ENCRYPTION_KEY: str = "change-this-to-a-secure-encryption-key"
# OAuth2 - Google
GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None
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_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
# Stripe Payment
STRIPE_API_KEY: Optional[str] = None
STRIPE_WEBHOOK_SECRET: Optional[str] = None
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
# Subscription Tiers
TIER_FREE_MAX_ACCOUNTS: int = 1
TIER_BASIC_MAX_ACCOUNTS: int = 5
TIER_PRO_MAX_ACCOUNTS: int = 20
TIER_ENTERPRISE_MAX_ACCOUNTS: int = 100
# Email Processing
MAX_EMAILS_PER_RUN: int = 50
CHECK_INTERVAL_MINUTES: int = 5
THROTTLE_EMAILS_PER_MINUTE: int = 10
# Redis (for Celery and caching)
REDIS_URL: str = "redis://localhost:6379/0"
# Celery
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
# Apprise (notifications)
APPRISE_ENABLED: bool = True
# Logging
LOG_LEVEL: str = "INFO"
# Admin
ADMIN_EMAIL: Optional[str] = None
ADMIN_PASSWORD: Optional[str] = None
# Mail Server Presets
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def assemble_cors_origins(cls, v: str | List[str]) -> List[str]:
@@ -92,7 +96,7 @@ class Settings(BaseSettings):
if isinstance(v, str):
return [i.strip() for i in v.split(",")]
return v
@field_validator("SECRET_KEY")
@classmethod
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))'"
)
return v
@field_validator("ENCRYPTION_KEY")
@classmethod
def validate_encryption_key(cls, v: str) -> str:
+1
View File
@@ -1,6 +1,7 @@
"""
Database configuration and session management.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.core.config import settings
+24 -27
View File
@@ -1,9 +1,14 @@
"""
Authentication dependencies for FastAPI.
"""
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
from fastapi.security import (
OAuth2PasswordBearer,
HTTPBearer,
HTTPAuthorizationCredentials,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -19,7 +24,7 @@ http_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(http_bearer),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> User:
"""
Get current authenticated user from JWT token.
@@ -27,14 +32,14 @@ async def get_current_user(
"""
# Get token from either source
auth_token = token or (credentials.credentials if credentials else None)
if not auth_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Decode token
payload = decode_token(auth_token)
if not payload:
@@ -43,7 +48,7 @@ async def get_current_user(
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify token type
token_type = payload.get("type")
if token_type != "access":
@@ -52,7 +57,7 @@ async def get_current_user(
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user ID from token
user_id: Optional[int] = payload.get("sub")
if user_id is None:
@@ -61,24 +66,23 @@ async def get_current_user(
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
# Fetch user from database
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
return user
@@ -88,8 +92,7 @@ async def get_current_active_user(
"""Get current active user"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
@@ -100,8 +103,7 @@ async def get_current_superuser(
"""Get current superuser"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return current_user
@@ -111,23 +113,18 @@ def check_subscription_tier(required_tier: str):
Dependency factory to check if user has required subscription tier.
Returns a dependency function.
"""
tier_hierarchy = {
"free": 0,
"basic": 1,
"pro": 2,
"enterprise": 3
}
tier_hierarchy = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
async def check_tier(current_user: User = Depends(get_current_active_user)) -> User:
user_tier_level = tier_hierarchy.get(current_user.subscription_tier.value, 0)
required_tier_level = tier_hierarchy.get(required_tier, 0)
if user_tier_level < required_tier_level:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"This feature requires {required_tier} subscription or higher"
detail=f"This feature requires {required_tier} subscription or higher",
)
return current_user
return check_tier
+21 -18
View File
@@ -1,6 +1,7 @@
"""
Security middleware for adding security headers and CSRF protection.
"""
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
@@ -9,24 +10,26 @@ import secrets
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Enable XSS protection (for older browsers)
response.headers["X-XSS-Protection"] = "1; mode=block"
# Strict Transport Security (HTTPS only)
# Note: Only enable in production with HTTPS
if request.url.hostname not in ["localhost", "127.0.0.1"]:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
# Content Security Policy (adjust based on frontend needs)
csp = (
"default-src 'self'; "
@@ -38,15 +41,15 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"frame-src https://js.stripe.com;"
)
response.headers["Content-Security-Policy"] = csp
# Referrer Policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions Policy (formerly Feature Policy)
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()"
)
return response
@@ -55,7 +58,7 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
Basic CSRF protection for state-changing operations.
For API-only applications, this is less critical but still good practice.
"""
def __init__(self, app: ASGIApp, exempt_paths: list = None):
super().__init__(app)
self.exempt_paths = exempt_paths or [
@@ -66,20 +69,20 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
"/openapi.json",
"/health",
]
async def dispatch(self, request: Request, call_next) -> Response:
# Skip CSRF check for safe methods
if request.method in ["GET", "HEAD", "OPTIONS"]:
return await call_next(request)
# Skip CSRF check for exempt paths
if any(request.url.path.startswith(path) for path in self.exempt_paths):
return await call_next(request)
# For API endpoints using JWT, the token itself provides CSRF protection
# This is because attackers can't access the token stored in httpOnly cookies
# or local storage from a different origin
# If implementing cookie-based sessions, would check CSRF token here:
# csrf_token = request.headers.get("X-CSRF-Token")
# if not csrf_token or not self._validate_csrf_token(csrf_token):
@@ -87,15 +90,15 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware):
# status_code=403,
# content={"detail": "CSRF token missing or invalid"}
# )
response = await call_next(request)
return response
@staticmethod
def _generate_csrf_token() -> str:
"""Generate a secure CSRF token"""
return secrets.token_urlsafe(32)
@staticmethod
def _validate_csrf_token(token: str) -> bool:
"""Validate CSRF token (implement actual validation logic)"""
+35 -25
View File
@@ -1,6 +1,8 @@
"""
Security utilities for encryption, hashing, and token generation.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
@@ -8,12 +10,11 @@ from jose import JWTError, jwt
from passlib.context import CryptContext
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
from app.core.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -28,17 +29,23 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
def create_access_token(
data: Dict[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
@@ -47,14 +54,18 @@ def create_refresh_token(data: Dict[str, Any]) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode and validate JWT token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
return None
@@ -67,49 +78,48 @@ def generate_random_token(length: int = 32) -> str:
class CredentialEncryption:
"""Handles encryption/decryption of sensitive credentials (POP3/IMAP passwords)"""
def __init__(self, key: Optional[str] = None, user_id: Optional[int] = None):
"""
Initialize encryption with a key.
If no key provided, uses the one from settings.
In production, use a unique salt per user for enhanced security.
Args:
key: Encryption key (defaults to settings.ENCRYPTION_KEY)
user_id: Optional user ID for per-user salt generation
"""
if key is None:
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:
# Per-user salt for production
salt = f'pop3_forwarder_user_{user_id}'.encode('utf-8')[:16].ljust(16, b'0')
salt = hashlib.sha256(f"pop3fwd_usr_{user_id}".encode()).digest()[:16]
else:
# Default salt for system-wide operations (use with caution)
salt = b'pop3_forwarder_0'
salt = b"pop3_forwarder_0"
# Derive a proper Fernet key from the provided key
kdf = PBKDF2(
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key_bytes = key.encode('utf-8')
key_bytes = key.encode("utf-8")
derived_key = base64.urlsafe_b64encode(kdf.derive(key_bytes))
self.fernet = Fernet(derived_key)
def encrypt(self, plain_text: str) -> str:
"""Encrypt a string and return base64-encoded ciphertext"""
encrypted = self.fernet.encrypt(plain_text.encode('utf-8'))
return base64.b64encode(encrypted).decode('utf-8')
encrypted = self.fernet.encrypt(plain_text.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
def decrypt(self, encrypted_text: str) -> str:
"""Decrypt a base64-encoded ciphertext"""
encrypted_bytes = base64.b64decode(encrypted_text.encode('utf-8'))
encrypted_bytes = base64.b64decode(encrypted_text.encode("utf-8"))
decrypted = self.fernet.decrypt(encrypted_bytes)
return decrypted.decode('utf-8')
return decrypted.decode("utf-8")
# Global encryption instance
+13 -12
View File
@@ -1,6 +1,7 @@
"""
Main FastAPI application.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -14,7 +15,7 @@ from app.api.v1.api import api_router
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@@ -22,20 +23,20 @@ logger = logging.getLogger(__name__)
def create_application() -> FastAPI:
"""Create and configure FastAPI application"""
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
openapi_url="/api/openapi.json",
)
# Security middleware (add before CORS)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(CSRFProtectionMiddleware)
# CORS middleware
app.add_middleware(
CORSMiddleware,
@@ -44,36 +45,36 @@ def create_application() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "POP3 Forwarder SaaS API",
"version": settings.APP_VERSION,
"docs": "/api/docs"
"docs": "/api/docs",
}
@app.get("/health")
async def health_check():
"""Health check endpoint for container orchestration"""
return {"status": "healthy"}
@app.on_event("startup")
async def startup_event():
"""Run on application startup"""
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"API documentation: /api/docs")
@app.on_event("shutdown")
async def shutdown_event():
"""Run on application shutdown"""
logger.info("Shutting down application")
return app
+25 -6
View File
@@ -1,12 +1,31 @@
"""Models package"""
from app.models.database_models import (
User, MailAccount, ProcessingRun, ProcessingLog,
NotificationConfig, MailServerPreset, SubscriptionPlan, AuditLog,
SubscriptionTier, MailProtocol, AccountStatus, NotificationChannel
User,
MailAccount,
ProcessingRun,
ProcessingLog,
NotificationConfig,
MailServerPreset,
SubscriptionPlan,
AuditLog,
SubscriptionTier,
MailProtocol,
AccountStatus,
NotificationChannel,
)
__all__ = [
"User", "MailAccount", "ProcessingRun", "ProcessingLog",
"NotificationConfig", "MailServerPreset", "SubscriptionPlan", "AuditLog",
"SubscriptionTier", "MailProtocol", "AccountStatus", "NotificationChannel"
"User",
"MailAccount",
"ProcessingRun",
"ProcessingLog",
"NotificationConfig",
"MailServerPreset",
"SubscriptionPlan",
"AuditLog",
"SubscriptionTier",
"MailProtocol",
"AccountStatus",
"NotificationChannel",
]
+187 -86
View File
@@ -1,11 +1,21 @@
"""
Database models for the multi-tenant POP3 forwarder application.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column, Integer, String, Boolean, DateTime, ForeignKey,
Text, Enum as SQLEnum, JSON, Float, Index
Column,
Integer,
String,
Boolean,
DateTime,
ForeignKey,
Text,
Enum as SQLEnum,
JSON,
Float,
Index,
)
from sqlalchemy.orm import relationship
import enum
@@ -15,6 +25,7 @@ from app.core.database import Base
class SubscriptionTier(str, enum.Enum):
"""Subscription tier levels"""
FREE = "free"
BASIC = "basic"
PRO = "pro"
@@ -23,14 +34,23 @@ class SubscriptionTier(str, enum.Enum):
class MailProtocol(str, enum.Enum):
"""Supported mail protocols"""
POP3 = "pop3"
POP3_SSL = "pop3_ssl"
IMAP = "imap"
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):
"""Mail account status"""
ACTIVE = "active"
INACTIVE = "inactive"
ERROR = "error"
@@ -39,6 +59,7 @@ class AccountStatus(str, enum.Enum):
class NotificationChannel(str, enum.Enum):
"""Notification channel types"""
EMAIL = "email"
TELEGRAM = "telegram"
WEBHOOK = "webhook"
@@ -48,73 +69,92 @@ class NotificationChannel(str, enum.Enum):
class User(Base):
"""User model - represents a user account"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=True) # Nullable for OAuth-only users
hashed_password = Column(
String(255), nullable=True
) # Nullable for OAuth-only users
full_name = Column(String(255))
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# OAuth
google_id = Column(String(255), unique=True, index=True, nullable=True)
oauth_provider = Column(String(50), nullable=True)
# Subscription
subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE)
subscription_status = Column(String(50), default="active") # active, canceled, past_due
subscription_status = Column(
String(50), default="active"
) # active, canceled, past_due
stripe_customer_id = Column(String(255), unique=True, nullable=True)
stripe_subscription_id = Column(String(255), unique=True, nullable=True)
subscription_expires_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
last_login_at = Column(DateTime, nullable=True)
# Relationships
mail_accounts = relationship("MailAccount", back_populates="user", cascade="all, delete-orphan")
notifications = relationship("NotificationConfig", back_populates="user", cascade="all, delete-orphan")
logs = relationship("ProcessingLog", back_populates="user", cascade="all, delete-orphan")
mail_accounts = relationship(
"MailAccount", back_populates="user", cascade="all, delete-orphan"
)
notifications = relationship(
"NotificationConfig", back_populates="user", cascade="all, delete-orphan"
)
logs = relationship(
"ProcessingLog", back_populates="user", cascade="all, delete-orphan"
)
class MailAccount(Base):
"""Mail account configuration (POP3/IMAP)"""
__tablename__ = "mail_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Account details
name = Column(String(255), nullable=False) # User-friendly name
email_address = Column(String(255), nullable=False)
# Server configuration
protocol = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL)
host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False)
use_ssl = Column(Boolean, default=True)
use_tls = Column(Boolean, default=False)
# Credentials (encrypted)
username = Column(String(255), nullable=False)
encrypted_password = Column(Text, nullable=False)
# Forwarding destination
forward_to = Column(String(255), nullable=False)
# Delivery method
delivery_method = Column(SQLEnum(DeliveryMethod), default=DeliveryMethod.GMAIL_API)
# Status and settings
status = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE)
is_enabled = Column(Boolean, default=True)
check_interval_minutes = Column(Integer, default=5)
max_emails_per_check = Column(Integer, default=50)
delete_after_forward = Column(Boolean, default=True)
# Auto-detection metadata
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
auto_detected = Column(Boolean, default=False)
# Statistics
total_emails_processed = 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_error_at = Column(DateTime, nullable=True)
last_error_message = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
user = relationship("User", back_populates="mail_accounts")
processing_runs = relationship("ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan")
processing_runs = relationship(
"ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan"
)
# Indexes
__table_args__ = (
Index('idx_user_email', 'user_id', 'email_address'),
Index('idx_status_enabled', 'status', 'is_enabled'),
Index("idx_user_email", "user_id", "email_address"),
Index("idx_status_enabled", "status", "is_enabled"),
)
class ProcessingRun(Base):
"""Records of email processing runs for each mail account"""
__tablename__ = "processing_runs"
id = Column(Integer, primary_key=True, index=True)
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
mail_account_id = Column(
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
)
# Run details
started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
completed_at = Column(DateTime, nullable=True)
duration_seconds = Column(Float, nullable=True)
# Results
emails_fetched = Column(Integer, default=0)
emails_forwarded = Column(Integer, default=0)
emails_failed = Column(Integer, default=0)
# Status
status = Column(String(50), default="running") # running, completed, failed
error_message = Column(Text, nullable=True)
# Relationships
mail_account = relationship("MailAccount", back_populates="processing_runs")
# Indexes
__table_args__ = (
Index('idx_account_started', 'mail_account_id', 'started_at'),
)
__table_args__ = (Index("idx_account_started", "mail_account_id", "started_at"),)
class ProcessingLog(Base):
"""Detailed logs of individual email processing attempts"""
__tablename__ = "processing_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
processing_run_id = Column(Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
mail_account_id = Column(
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
)
processing_run_id = Column(
Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True
)
# Log details
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
level = Column(String(20), nullable=False) # INFO, WARNING, ERROR
message = Column(Text, nullable=False)
# Email metadata (if applicable)
email_subject = Column(String(500), nullable=True)
email_from = Column(String(255), nullable=True)
email_size_bytes = Column(Integer, nullable=True)
# Status
success = Column(Boolean, default=True)
error_details = Column(JSON, nullable=True)
# Relationships
user = relationship("User", back_populates="logs")
# Indexes
__table_args__ = (
Index('idx_user_timestamp', 'user_id', 'timestamp'),
Index('idx_account_timestamp', 'mail_account_id', 'timestamp'),
Index("idx_user_timestamp", "user_id", "timestamp"),
Index("idx_account_timestamp", "mail_account_id", "timestamp"),
)
class NotificationConfig(Base):
"""User notification channel configurations"""
__tablename__ = "notification_configs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Channel details
channel = Column(SQLEnum(NotificationChannel), nullable=False)
is_enabled = Column(Boolean, default=True)
# Channel-specific configuration (stored as JSON)
config = Column(JSON, nullable=False)
# Examples:
# EMAIL: {"address": "user@example.com"}
# TELEGRAM: {"bot_token": "xxx", "chat_id": "yyy"}
# WEBHOOK: {"url": "https://example.com/webhook", "headers": {...}}
# Notification preferences
notify_on_errors = Column(Boolean, default=True)
notify_on_success = Column(Boolean, default=False)
notify_threshold = Column(Integer, default=3) # Notify after N consecutive errors
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
user = relationship("User", back_populates="notifications")
# Indexes
__table_args__ = (
Index('idx_user_channel', 'user_id', 'channel'),
)
__table_args__ = (Index("idx_user_channel", "user_id", "channel"),)
class MailServerPreset(Base):
"""Predefined mail server configurations for common providers"""
__tablename__ = "mail_server_presets"
id = Column(Integer, primary_key=True, index=True)
# Provider info
provider_name = Column(String(100), unique=True, nullable=False, index=True)
provider_domain = Column(String(255), nullable=False) # e.g., "gmail.com"
# Server configurations (can have multiple protocols)
configs = Column(JSON, nullable=False)
# Example:
@@ -254,75 +310,120 @@ class MailServerPreset(Base):
# "pop3_ssl": {"host": "pop.gmail.com", "port": 995, "ssl": true},
# "imap_ssl": {"host": "imap.gmail.com", "port": 993, "ssl": true}
# }
# Metadata
is_verified = Column(Boolean, default=False)
popularity_score = Column(Integer, default=0) # For sorting recommendations
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class SubscriptionPlan(Base):
"""Available subscription plans and their features"""
__tablename__ = "subscription_plans"
id = Column(Integer, primary_key=True, index=True)
# Plan details
tier = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Pricing
price_monthly = Column(Float, nullable=False)
price_yearly = Column(Float, nullable=True)
# Stripe integration
stripe_price_id_monthly = Column(String(255), nullable=True)
stripe_price_id_yearly = Column(String(255), nullable=True)
# Features/Limits
max_mail_accounts = Column(Integer, nullable=False)
max_emails_per_day = Column(Integer, nullable=False)
check_interval_minutes = Column(Integer, nullable=False)
support_level = Column(String(50), default="community") # community, email, priority
support_level = Column(
String(50), default="community"
) # community, email, priority
features = Column(JSON, nullable=True) # Additional features as JSON
# Status
is_active = Column(Boolean, default=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class AuditLog(Base):
"""Audit trail for security and compliance"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
# Who
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
user_email = Column(String(255), nullable=True) # Cached for deleted users
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
# What
action = Column(String(100), nullable=False, index=True)
resource_type = Column(String(50), nullable=True)
resource_id = Column(Integer, nullable=True)
# Details
details = Column(JSON, nullable=True)
status = Column(String(20), default="success") # success, failure
# When
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Indexes
__table_args__ = (
Index('idx_user_action', 'user_id', 'action'),
Index('idx_timestamp_action', 'timestamp', 'action'),
Index("idx_user_action", "user_id", "action"),
Index("idx_timestamp_action", "timestamp", "action"),
)
class GmailCredential(Base):
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
__tablename__ = "gmail_credentials"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
# 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.
"""
from datetime import datetime
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, EmailStr, Field, validator
@@ -29,6 +30,11 @@ class AccountStatus(str, Enum):
TESTING = "testing"
class DeliveryMethod(str, Enum):
SMTP = "smtp"
GMAIL_API = "gmail_api"
class NotificationChannel(str, Enum):
EMAIL = "email"
TELEGRAM = "telegram"
@@ -58,7 +64,7 @@ class UserResponse(UserBase):
subscription_tier: SubscriptionTier
subscription_status: str
created_at: datetime
class Config:
from_attributes = True
@@ -69,7 +75,7 @@ class UserDetailResponse(UserResponse):
stripe_customer_id: Optional[str] = None
subscription_expires_at: Optional[datetime] = None
last_login_at: Optional[datetime] = None
class Config:
from_attributes = True
@@ -103,6 +109,7 @@ class MailAccountBase(BaseModel):
use_tls: bool = False
username: str = Field(..., max_length=255)
forward_to: EmailStr
delivery_method: DeliveryMethod = DeliveryMethod.GMAIL_API
is_enabled: bool = True
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
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)
password: Optional[str] = None
forward_to: Optional[EmailStr] = None
delivery_method: Optional[DeliveryMethod] = None
is_enabled: Optional[bool] = None
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
@@ -127,6 +135,7 @@ class MailAccountResponse(MailAccountBase):
id: int
user_id: int
status: AccountStatus
delivery_method: DeliveryMethod
provider_name: Optional[str] = None
auto_detected: bool
total_emails_processed: int
@@ -137,17 +146,18 @@ class MailAccountResponse(MailAccountBase):
last_error_message: Optional[str] = None
created_at: datetime
updated_at: datetime
# Don't expose password or username in responses
password: str = Field(exclude=True, default="")
username: str = Field(exclude=True, default="")
class Config:
from_attributes = True
class MailAccountTestRequest(BaseModel):
"""Test connection to mail server"""
host: str
port: int
protocol: MailProtocol
@@ -165,6 +175,7 @@ class MailAccountTestResponse(BaseModel):
class MailAccountAutoDetectRequest(BaseModel):
"""Auto-detect mail server settings"""
email_address: EmailStr
@@ -185,7 +196,7 @@ class ProcessingRunResponse(BaseModel):
emails_failed: int
status: str
error_message: Optional[str] = None
class Config:
from_attributes = True
@@ -199,7 +210,7 @@ class ProcessingLogResponse(BaseModel):
email_subject: Optional[str] = None
email_from: Optional[str] = None
success: bool
class Config:
from_attributes = True
@@ -231,7 +242,7 @@ class NotificationConfigResponse(NotificationConfigBase):
user_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@@ -250,7 +261,7 @@ class SubscriptionPlanResponse(BaseModel):
support_level: str
features: Optional[Dict[str, Any]] = None
is_active: bool
class Config:
from_attributes = True
@@ -299,6 +310,41 @@ class MailServerPresetResponse(BaseModel):
provider_domain: str
configs: Dict[str, Any]
is_verified: bool
class Config:
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.
"""
from typing import Dict, Any, Optional
from datetime import datetime
import httpx
@@ -17,30 +18,32 @@ logger = logging.getLogger(__name__)
class OAuthService:
"""OAuth2 authentication service"""
def __init__(self):
self.oauth = OAuth()
self._register_google()
def _register_google(self):
"""Register Google OAuth2 provider"""
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
self.oauth.register(
name='google',
name="google",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'}
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
async def get_google_user_info(self, code: str, redirect_uri: str) -> Dict[str, Any]:
async def get_google_user_info(
self, code: str, redirect_uri: str
) -> Dict[str, Any]:
"""
Exchange Google authorization code for user information.
Args:
code: Authorization code from Google
redirect_uri: Redirect URI used in OAuth flow
Returns:
Dict with user information (email, name, google_id)
"""
@@ -48,82 +51,84 @@ class OAuthService:
# Exchange code for token
async with httpx.AsyncClient() as client:
token_response = await client.post(
'https://oauth2.googleapis.com/token',
"https://oauth2.googleapis.com/token",
data={
'code': code,
'client_id': settings.GOOGLE_CLIENT_ID,
'client_secret': settings.GOOGLE_CLIENT_SECRET,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code'
}
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
logger.error(f"Google token exchange failed: {token_response.text}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code"
detail="Failed to exchange authorization code",
)
token_data = token_response.json()
access_token = token_data.get('access_token')
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No access token received"
detail="No access token received",
)
# Get user info
user_info_response = await client.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
headers={'Authorization': f'Bearer {access_token}'}
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if user_info_response.status_code != 200:
logger.error(f"Google user info fetch failed: {user_info_response.text}")
logger.error(
f"Google user info fetch failed: {user_info_response.text}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user information"
detail="Failed to get user information",
)
user_info = user_info_response.json()
return {
'email': user_info.get('email'),
'full_name': user_info.get('name'),
'google_id': user_info.get('id'),
'picture': user_info.get('picture'),
'verified_email': user_info.get('verified_email', False)
"email": user_info.get("email"),
"full_name": user_info.get("name"),
"google_id": user_info.get("id"),
"picture": user_info.get("picture"),
"verified_email": user_info.get("verified_email", False),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"OAuth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed"
detail="OAuth authentication failed",
)
@staticmethod
def create_tokens_for_user(user: User) -> Dict[str, str]:
"""
Create access and refresh tokens for a user.
Args:
user: User database model
Returns:
Dict with access_token, refresh_token, and token_type
"""
access_token = create_access_token(data={"sub": user.id})
refresh_token = create_refresh_token(data={"sub": user.id})
return {
"access_token": access_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.
Supports both POP3 and IMAP protocols with secure connections.
"""
import asyncio
import poplib
import smtplib
@@ -23,31 +24,35 @@ logger = logging.getLogger(__name__)
class MailConnectionError(Exception):
"""Raised when unable to connect to mail server"""
pass
class MailAuthenticationError(Exception):
"""Raised when authentication fails"""
pass
class MailFetchError(Exception):
"""Raised when fetching emails fails"""
pass
class MailForwardError(Exception):
"""Raised when forwarding email fails"""
pass
class MailProcessor:
"""Handles mail fetching and forwarding operations"""
def __init__(self, account: MailAccount, decrypted_password: str):
self.account = account
self.password = decrypted_password
async def test_connection(self) -> Tuple[bool, str]:
"""
Test connection to mail server.
@@ -61,12 +66,12 @@ class MailProcessor:
except Exception as e:
logger.error(f"Connection test failed: {e}")
return False, str(e)
async def _test_pop3_connection(self) -> Tuple[bool, str]:
"""Test POP3 connection"""
try:
loop = asyncio.get_event_loop()
# Run blocking POP3 operations in thread pool
def connect_pop3():
if self.account.protocol == MailProtocol.POP3_SSL:
@@ -75,29 +80,27 @@ class MailProcessor:
self.account.host,
self.account.port,
context=context,
timeout=10
timeout=10,
)
else:
pop_conn = poplib.POP3(
self.account.host,
self.account.port,
timeout=10
self.account.host, self.account.port, timeout=10
)
# Try authentication
pop_conn.user(self.account.username)
pop_conn.pass_(self.password)
# Get mailbox stats
message_count, mailbox_size = pop_conn.stat()
pop_conn.quit()
return message_count, mailbox_size
message_count, mailbox_size = await loop.run_in_executor(None, connect_pop3)
return True, f"Connection successful. {message_count} messages in mailbox."
except poplib.error_proto as e:
error_msg = str(e)
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}"
except Exception as e:
return False, f"Connection failed: {str(e)}"
async def _test_imap_connection(self) -> Tuple[bool, str]:
"""Test IMAP connection"""
try:
# Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host,
port=self.account.port,
timeout=10
host=self.account.host, port=self.account.port, timeout=10
)
else:
imap_client = aioimaplib.IMAP4(
host=self.account.host,
port=self.account.port,
timeout=10
host=self.account.host, port=self.account.port, timeout=10
)
await imap_client.wait_hello_from_server()
# Authenticate
response = await imap_client.login(self.account.username, self.password)
if response.result != 'OK':
if response.result != "OK":
return False, f"Authentication failed: {response.lines}"
# Select inbox
await imap_client.select('INBOX')
await imap_client.select("INBOX")
# Get message count
response = await imap_client.search('ALL')
response = await imap_client.search("ALL")
message_ids = response.lines[0].split()
message_count = len(message_ids)
await imap_client.logout()
return True, f"Connection successful. {message_count} messages in mailbox."
except Exception as e:
return False, f"IMAP connection failed: {str(e)}"
async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]:
"""
Fetch emails from the mail server.
Returns list of raw email data.
"""
max_count = max_count or self.account.max_emails_per_check
if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]:
return await self._fetch_pop3_emails(max_count)
else:
return await self._fetch_imap_emails(max_count)
async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via POP3"""
emails = []
try:
loop = asyncio.get_event_loop()
def fetch_pop3():
# Connect
if self.account.protocol == MailProtocol.POP3_SSL:
@@ -173,37 +172,39 @@ class MailProcessor:
self.account.host,
self.account.port,
context=context,
timeout=30
timeout=30,
)
else:
pop_conn = poplib.POP3(
self.account.host,
self.account.port,
timeout=30
self.account.host, self.account.port, timeout=30
)
# Authenticate
pop_conn.user(self.account.username)
pop_conn.pass_(self.password)
# Get message count
num_messages = len(pop_conn.list()[1])
logger.info(f"Found {num_messages} messages for account {self.account.id}")
logger.info(
f"Found {num_messages} messages for account {self.account.id}"
)
fetched_emails = []
messages_to_delete = []
# Fetch emails (limited by max_count)
for i in range(1, min(num_messages + 1, max_count + 1)):
try:
response, lines, octets = pop_conn.retr(i)
email_data = b'\r\n'.join(lines)
email_data = b"\r\n".join(lines)
fetched_emails.append(email_data)
messages_to_delete.append(i)
logger.info(f"Retrieved message {i} from account {self.account.id}")
logger.info(
f"Retrieved message {i} from account {self.account.id}"
)
except Exception as e:
logger.error(f"Error retrieving message {i}: {e}")
# Delete messages if configured
if self.account.delete_after_forward:
for msg_id in messages_to_delete:
@@ -211,100 +212,98 @@ class MailProcessor:
pop_conn.dele(msg_id)
except Exception as e:
logger.error(f"Error deleting message {msg_id}: {e}")
pop_conn.quit()
return fetched_emails
emails = await loop.run_in_executor(None, fetch_pop3)
except Exception as e:
logger.error(f"Error fetching POP3 emails: {e}")
raise MailFetchError(f"POP3 fetch error: {str(e)}")
return emails
async def _fetch_imap_emails(self, max_count: int) -> List[bytes]:
"""Fetch emails via IMAP"""
emails = []
try:
# Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host,
port=self.account.port,
timeout=30
host=self.account.host, port=self.account.port, timeout=30
)
else:
imap_client = aioimaplib.IMAP4(
host=self.account.host,
port=self.account.port,
timeout=30
host=self.account.host, port=self.account.port, timeout=30
)
await imap_client.wait_hello_from_server()
await imap_client.login(self.account.username, self.password)
await imap_client.select('INBOX')
await imap_client.select("INBOX")
# Search for all messages
response = await imap_client.search('UNSEEN') # Only fetch unread
response = await imap_client.search("UNSEEN") # Only fetch unread
message_ids = response.lines[0].split()
# Limit to max_count
message_ids = message_ids[:max_count]
logger.info(f"Found {len(message_ids)} unread messages for account {self.account.id}")
logger.info(
f"Found {len(message_ids)} unread messages for account {self.account.id}"
)
# Fetch each message
for msg_id in message_ids:
try:
response = await imap_client.fetch(msg_id, '(RFC822)')
response = await imap_client.fetch(msg_id, "(RFC822)")
# Extract email data from response
email_data = None
for line in response.lines:
if isinstance(line, bytes) and b'RFC822' in line:
if isinstance(line, bytes) and b"RFC822" in line:
# Find the email content
start_idx = line.find(b'{')
start_idx = line.find(b"{")
if start_idx != -1:
# Email data is in the next parts
continue
elif isinstance(line, bytes) and not line.startswith(b'*'):
elif isinstance(line, bytes) and not line.startswith(b"*"):
email_data = line
break
if email_data:
emails.append(email_data)
# Mark as seen if deleting after forward
if self.account.delete_after_forward:
await imap_client.store(msg_id, '+FLAGS', '\\Deleted')
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
except Exception as e:
logger.error(f"Error fetching message {msg_id}: {e}")
# Expunge deleted messages
if self.account.delete_after_forward:
await imap_client.expunge()
await imap_client.logout()
except Exception as e:
logger.error(f"Error fetching IMAP emails: {e}")
raise MailFetchError(f"IMAP fetch error: {str(e)}")
return emails
@staticmethod
async def forward_email(
email_data: bytes,
source_account_name: str,
destination: str,
smtp_config: Dict[str, Any]
smtp_config: Dict[str, Any],
) -> bool:
"""
Forward an email to the destination address.
Args:
email_data: Raw email bytes
source_account_name: Name of source account for labeling
@@ -315,60 +314,68 @@ class MailProcessor:
- username: SMTP username
- password: SMTP password
- use_tls: Whether to use STARTTLS
Returns:
True if successful, False otherwise
"""
try:
loop = asyncio.get_event_loop()
def send_email():
# Parse the email
msg = parser.BytesParser().parsebytes(email_data)
# Create forwarding message
forward_msg = MIMEMultipart('mixed')
forward_msg['From'] = smtp_config['username']
forward_msg['To'] = destination
forward_msg['Date'] = formatdate(localtime=True)
forward_msg['Message-ID'] = make_msgid()
forward_msg = MIMEMultipart("mixed")
forward_msg["From"] = smtp_config["username"]
forward_msg["To"] = destination
forward_msg["Date"] = formatdate(localtime=True)
forward_msg["Message-ID"] = make_msgid()
# Preserve original subject with prefix
original_subject = msg.get('Subject', 'No Subject')
forward_msg['Subject'] = f"[Fwd from {source_account_name}] {original_subject}"
original_subject = msg.get("Subject", "No Subject")
forward_msg["Subject"] = (
f"[Fwd from {source_account_name}] {original_subject}"
)
# Add original headers
header_info = f"Originally from: {msg.get('From', 'Unknown')}\n"
header_info += f"Original Date: {msg.get('Date', 'Unknown')}\n"
header_info += f"Original Subject: {original_subject}\n"
header_info += f"Source Account: {source_account_name}\n"
header_info += "-" * 50 + "\n\n"
# Get email body
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
body = part.get_payload(decode=True).decode(
"utf-8", errors="ignore"
)
break
else:
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
body = payload.decode("utf-8", errors="ignore")
# Combine header and body
full_body = header_info + body
forward_msg.attach(MIMEText(full_body, 'plain', 'utf-8'))
forward_msg.attach(MIMEText(full_body, "plain", "utf-8"))
# Send via SMTP
if smtp_config.get('use_tls', True):
server = smtplib.SMTP(smtp_config['host'], smtp_config['port'], timeout=30)
if smtp_config.get("use_tls", True):
server = smtplib.SMTP(
smtp_config["host"], smtp_config["port"], timeout=30
)
server.starttls()
else:
server = smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port'], timeout=30)
server = smtplib.SMTP_SSL(
smtp_config["host"], smtp_config["port"], timeout=30
)
try:
server.login(smtp_config['username'], smtp_config['password'])
server.login(smtp_config["username"], smtp_config["password"])
server.send_message(forward_msg)
logger.info(f"Successfully forwarded email to {destination}")
return True
@@ -377,9 +384,9 @@ class MailProcessor:
server.quit()
except Exception as e:
logger.warning(f"Error closing SMTP connection: {e}")
return await loop.run_in_executor(None, send_email)
except Exception as e:
logger.error(f"Error forwarding email: {e}")
raise MailForwardError(f"Forward error: {str(e)}")
@@ -387,7 +394,7 @@ class MailProcessor:
class MailServerAutoDetect:
"""Auto-detect mail server settings based on email domain"""
# Common mail server configurations
KNOWN_PROVIDERS = {
"gmail.com": {
@@ -395,6 +402,11 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "pop.gmail.com", "port": 995},
"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": {
"name": "Outlook.com",
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
@@ -405,6 +417,21 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
"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": {
"name": "GMX",
"pop3_ssl": {"host": "pop.gmx.com", "port": 995},
@@ -415,6 +442,21 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
"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": {
"name": "WEB.DE",
"pop3_ssl": {"host": "pop3.web.de", "port": 995},
@@ -422,86 +464,162 @@ class MailServerAutoDetect:
},
"t-online.de": {
"name": "T-Online",
"pop3_ssl": {"host": "pop.t-online.de", "port": 995},
"imap_ssl": {"host": "imap.t-online.de", "port": 993},
"pop3_ssl": {"host": "securepop.t-online.de", "port": 995},
"imap_ssl": {"host": "secureimap.t-online.de", "port": 993},
},
"yahoo.com": {
"name": "Yahoo",
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
"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
def detect(cls, email_address: str) -> List[Dict[str, Any]]:
"""
Detect mail server settings for an email address.
Returns list of possible configurations.
"""
domain = email_address.split('@')[-1].lower()
domain = email_address.split("@")[-1].lower()
suggestions = []
# Check if we have a known provider
if domain in cls.KNOWN_PROVIDERS:
provider = cls.KNOWN_PROVIDERS[domain]
# Add POP3 SSL suggestion
if "pop3_ssl" in provider:
suggestions.append({
"protocol": "pop3_ssl",
"provider_name": provider["name"],
"host": provider["pop3_ssl"]["host"],
"port": provider["pop3_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
})
suggestions.append(
{
"protocol": "pop3_ssl",
"provider_name": provider["name"],
"host": provider["pop3_ssl"]["host"],
"port": provider["pop3_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
}
)
# Add IMAP SSL suggestion
if "imap_ssl" in provider:
suggestions.append({
"protocol": "imap_ssl",
"provider_name": provider["name"],
"host": provider["imap_ssl"]["host"],
"port": provider["imap_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
})
suggestions.append(
{
"protocol": "imap_ssl",
"provider_name": provider["name"],
"host": provider["imap_ssl"]["host"],
"port": provider["imap_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
}
)
else:
# Generic suggestions based on common patterns
suggestions.extend([
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop3.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"imap.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"mail.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
])
suggestions.extend(
[
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
"host": f"pop3.{domain}",
"port": 995,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"imap.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
{
"protocol": "imap_ssl",
"provider_name": "Generic",
"host": f"mail.{domain}",
"port": 993,
"use_ssl": True,
"use_tls": False,
},
]
)
return suggestions
+2 -1
View File
@@ -1,6 +1,7 @@
"""
Celery application for background email processing tasks.
"""
from celery import Celery
from celery.schedules import crontab
import logging
@@ -14,7 +15,7 @@ celery_app = Celery(
"pop3_forwarder",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
include=["app.workers.tasks"]
include=["app.workers.tasks"],
)
# Celery configuration
+127 -67
View File
@@ -1,6 +1,7 @@
"""
Celery tasks for background email processing.
"""
import asyncio
import os
from datetime import datetime, timedelta
@@ -11,8 +12,17 @@ import logging
from app.workers.celery_app import celery_app
from app.core.database import async_session_maker
from app.core.security import decrypt_credential
from app.models.database_models import MailAccount, ProcessingRun, ProcessingLog, AccountStatus
from app.models.database_models import (
MailAccount,
ProcessingRun,
ProcessingLog,
AccountStatus,
DeliveryMethod,
GmailCredential,
)
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.ext.asyncio import AsyncSession
@@ -21,7 +31,7 @@ logger = logging.getLogger(__name__)
class AsyncTask(Task):
"""Base task class that handles async operations"""
def __call__(self, *args, **kwargs):
"""Run async task in event loop"""
# Use asyncio.run() for better event loop management
@@ -32,7 +42,7 @@ class AsyncTask(Task):
async def process_mail_account(account_id: int):
"""
Process a single mail account - fetch and forward emails.
Args:
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)
)
account = result.scalar_one_or_none()
if not account or not account.is_enabled:
logger.warning(f"Account {account_id} not found or disabled")
return
# Create processing run
run = ProcessingRun(
mail_account_id=account.id,
started_at=datetime.utcnow(),
status="running"
status="running",
)
db.add(run)
await db.commit()
await db.refresh(run)
# Decrypt password
password = decrypt_credential(account.encrypted_password)
# Create processor
processor = MailProcessor(account, password)
# Fetch emails
emails = await processor.fetch_emails(account.max_emails_per_check)
run.emails_fetched = len(emails)
# Forward emails
emails_forwarded = 0
emails_failed = 0
# Get SMTP config from environment or user settings
# TODO: Make this configurable per user in the database
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 = "SMTP credentials not configured"
await db.commit()
return
# Determine delivery method
use_gmail_api = account.delivery_method == DeliveryMethod.GMAIL_API
gmail_service = None
smtp_config = None
if use_gmail_api:
# Get user's Gmail credentials
gmail_cred_result = await db.execute(
select(GmailCredential).where(
GmailCredential.user_id == account.user_id,
GmailCredential.is_valid == True,
)
)
gmail_cred = gmail_cred_result.scalar_one_or_none()
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:
try:
success = await MailProcessor.forward_email(
email_data,
account.name,
account.forward_to,
smtp_config
)
if success:
if use_gmail_api and gmail_service:
# Inject via Gmail API (preferred)
await gmail_service.inject_email(
raw_email=email_data,
label_ids=["INBOX"],
source_account_name=account.name,
)
emails_forwarded += 1
else:
emails_failed += 1
except Exception as e:
logger.error(f"Error forwarding email: {e}")
# Forward via SMTP (fallback)
success = await MailProcessor.forward_email(
email_data, account.name, account.forward_to, smtp_config
)
if success:
emails_forwarded += 1
else:
emails_failed += 1
except (GmailInjectionError, Exception) as e:
logger.error(f"Error delivering email: {e}")
emails_failed += 1
# Update run
run.emails_forwarded = emails_forwarded
run.emails_failed = emails_failed
run.completed_at = datetime.utcnow()
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
run.status = "completed" if emails_failed == 0 else "partial_failure"
# Update account
account.total_emails_processed += emails_forwarded
account.total_emails_failed += emails_failed
account.last_check_at = datetime.utcnow()
if emails_failed == 0:
account.last_successful_check_at = datetime.utcnow()
account.status = AccountStatus.ACTIVE
@@ -127,30 +181,32 @@ async def process_mail_account(account_id: int):
account.status = AccountStatus.ERROR
account.last_error_at = datetime.utcnow()
account.last_error_message = f"{emails_failed} emails failed to forward"
await db.commit()
logger.info(
f"Processed account {account.id}: "
f"{emails_forwarded} forwarded, {emails_failed} failed"
)
except Exception as e:
logger.error(f"Error processing account {account_id}: {e}")
# Mark run as failed
if 'run' in locals():
if "run" in locals():
run.status = "failed"
run.error_message = str(e)
run.completed_at = datetime.utcnow()
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
run.duration_seconds = (
run.completed_at - run.started_at
).total_seconds()
# Update account error status
if 'account' in locals():
if "account" in locals():
account.status = AccountStatus.ERROR
account.last_error_at = datetime.utcnow()
account.last_error_message = str(e)
await db.commit()
@@ -167,26 +223,30 @@ async def process_all_enabled_accounts():
select(MailAccount).where(
and_(
MailAccount.is_enabled == True,
MailAccount.status.in_([AccountStatus.ACTIVE, AccountStatus.TESTING])
MailAccount.status.in_(
[AccountStatus.ACTIVE, AccountStatus.TESTING]
),
)
)
)
accounts = result.scalars().all()
logger.info(f"Processing {len(accounts)} enabled mail accounts")
# Process each account
for account in accounts:
# Check if it's time to check this account
if account.last_check_at:
time_since_last_check = datetime.utcnow() - account.last_check_at
if time_since_last_check.total_seconds() < (account.check_interval_minutes * 60):
if time_since_last_check.total_seconds() < (
account.check_interval_minutes * 60
):
logger.debug(f"Skipping account {account.id} - not time yet")
continue
# Queue processing task
process_mail_account.delay(account.id)
except Exception as 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):
"""
Clean up old processing logs and runs.
Args:
days_to_keep: Number of days of logs to retain
"""
async with async_session_maker() as db:
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep)
# Delete old processing runs
result = await db.execute(
select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date)
)
old_runs = result.scalars().all()
for run in old_runs:
await db.delete(run)
# Delete old processing logs
result = await db.execute(
select(ProcessingLog).where(ProcessingLog.timestamp < cutoff_date)
)
old_logs = result.scalars().all()
for log in old_logs:
await db.delete(log)
await db.commit()
logger.info(
f"Cleaned up {len(old_runs)} old processing runs and "
f"{len(old_logs)} old logs"
)
except Exception as 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
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
celery==5.3.6
redis==5.0.1
@@ -50,6 +56,3 @@ faker==22.6.0
python-dotenv==1.0.0
schedule==1.2.0
tenacity==8.2.3
# Legacy support (for migration)
poplib3==0.0.4
+23 -16
View File
@@ -1,6 +1,7 @@
"""
Test configuration and fixtures.
"""
import pytest
import asyncio
from typing import AsyncGenerator, Generator
@@ -15,7 +16,9 @@ from app.models.database_models import User
from app.core.security import get_password_hash, create_access_token
# Test database URL (use different database for tests)
TEST_DATABASE_URL = settings.DATABASE_URL.replace("/pop3_forwarder", "/pop3_forwarder_test")
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
"/pop3_forwarder", "/pop3_forwarder_test"
)
# Note: event_loop fixture removed - pytest-asyncio provides this automatically
@@ -30,18 +33,18 @@ async def db_engine():
poolclass=NullPool,
echo=False,
)
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield engine
# Drop tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@@ -53,7 +56,7 @@ async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
class_=AsyncSession,
expire_on_commit=False,
)
async with async_session_maker() as session:
yield session
@@ -61,15 +64,15 @@ async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""Create test client with database session override"""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@@ -120,9 +123,11 @@ def admin_auth_headers(test_admin_user: User) -> dict:
# Factory fixtures for creating test data
@pytest.fixture
def user_factory(db_session: AsyncSession):
"""Factory for creating test users"""
async def _create_user(
email: str = None,
password: str = "testpassword123",
@@ -132,8 +137,9 @@ def user_factory(db_session: AsyncSession):
) -> User:
if email is None:
import uuid
email = f"test-{uuid.uuid4()}@example.com"
user = User(
email=email,
hashed_password=get_password_hash(password),
@@ -145,7 +151,7 @@ def user_factory(db_session: AsyncSession):
await db_session.commit()
await db_session.refresh(user)
return user
return _create_user
@@ -154,7 +160,7 @@ def mail_account_factory(db_session: AsyncSession):
"""Factory for creating test mail accounts"""
from app.models.database_models import MailAccount
from app.core.security import encrypt_password
async def _create_mail_account(
user_id: int,
host: str = "pop.example.com",
@@ -166,10 +172,11 @@ def mail_account_factory(db_session: AsyncSession):
) -> MailAccount:
if username is None:
import uuid
username = f"test-{uuid.uuid4()}@example.com"
encrypted_password = encrypt_password(password, user_id)
account = MailAccount(
user_id=user_id,
host=host,
@@ -184,5 +191,5 @@ def mail_account_factory(db_session: AsyncSession):
await db_session.commit()
await db_session.refresh(account)
return account
return _create_mail_account
+19 -12
View File
@@ -1,6 +1,7 @@
"""
Unit tests for configuration module.
"""
import pytest
from pydantic import ValidationError
from app.core.config import Settings
@@ -8,7 +9,7 @@ from app.core.config import Settings
class TestConfigValidation:
"""Test configuration validation"""
def test_default_secret_key_rejected(self):
"""Test that default SECRET_KEY is rejected"""
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",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
)
assert "SECRET_KEY must be changed from default" in str(exc_info.value)
def test_short_secret_key_rejected(self):
"""Test that short SECRET_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info:
@@ -26,9 +27,9 @@ class TestConfigValidation:
SECRET_KEY="short",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-testing",
)
assert "at least 32 characters" in str(exc_info.value)
def test_default_encryption_key_rejected(self):
"""Test that default ENCRYPTION_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info:
@@ -36,9 +37,9 @@ class TestConfigValidation:
SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
ENCRYPTION_KEY="change-this-to-a-secure-encryption-key",
)
assert "ENCRYPTION_KEY must be changed from default" in str(exc_info.value)
def test_short_encryption_key_rejected(self):
"""Test that short ENCRYPTION_KEY is rejected"""
with pytest.raises(ValidationError) as exc_info:
@@ -46,15 +47,21 @@ class TestConfigValidation:
SECRET_KEY="this-is-a-secure-32-character-key-for-testing",
ENCRYPTION_KEY="short",
)
assert "at least 32 characters" in str(exc_info.value)
def test_valid_keys_accepted(self):
"""Test that valid keys are accepted"""
settings = Settings(
SECRET_KEY="this-is-a-secure-32-character-key-for-testing-secret",
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-encryption",
)
assert settings.SECRET_KEY == "this-is-a-secure-32-character-key-for-testing-secret"
assert settings.ENCRYPTION_KEY == "this-is-a-secure-32-character-key-for-encryption"
assert (
settings.SECRET_KEY
== "this-is-a-secure-32-character-key-for-testing-secret"
)
assert (
settings.ENCRYPTION_KEY
== "this-is-a-secure-32-character-key-for-encryption"
)
+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.
"""
import pytest
from app.core.security import (
get_password_hash,
verify_password,
create_access_token,
encrypt_password,
decrypt_password,
CredentialEncryption,
)
class TestPasswordHashing:
"""Test password hashing and verification"""
def test_hash_password(self):
"""Test password hashing"""
password = "securepassword123"
hashed = get_password_hash(password)
assert hashed != password
assert len(hashed) > 50
assert hashed.startswith("$2b$")
def test_verify_password_success(self):
"""Test password verification with correct password"""
password = "securepassword123"
hashed = get_password_hash(password)
assert verify_password(password, hashed) is True
def test_verify_password_failure(self):
"""Test password verification with wrong password"""
password = "securepassword123"
wrong_password = "wrongpassword"
hashed = get_password_hash(password)
assert verify_password(wrong_password, hashed) is False
class TestJWT:
"""Test JWT token creation and validation"""
def test_create_access_token(self):
"""Test access token creation"""
data = {"sub": "test@example.com"}
token = create_access_token(data)
assert isinstance(token, str)
assert len(token) > 50
assert token.count('.') == 2 # JWT has 3 parts
assert token.count(".") == 2 # JWT has 3 parts
class TestEncryption:
"""Test credential encryption/decryption"""
def test_encrypt_password(self):
"""Test password encryption"""
password = "mailpassword123"
user_id = 1
encrypted = encrypt_password(password, user_id)
encryptor = CredentialEncryption(user_id=user_id)
encrypted = encryptor.encrypt(password)
assert encrypted != password
assert len(encrypted) > 50
def test_decrypt_password(self):
"""Test password decryption"""
password = "mailpassword123"
user_id = 1
encrypted = encrypt_password(password, user_id)
decrypted = decrypt_password(encrypted, user_id)
encryptor = CredentialEncryption(user_id=user_id)
encrypted = encryptor.encrypt(password)
decrypted = encryptor.decrypt(encrypted)
assert decrypted == password
def test_encryption_with_different_user_ids(self):
"""Test that encryption produces different results for different users"""
password = "mailpassword123"
user_id_1 = 1
user_id_2 = 2
encrypted_1 = encrypt_password(password, user_id_1)
encrypted_2 = encrypt_password(password, user_id_2)
encryptor_1 = CredentialEncryption(user_id=user_id_1)
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
assert encrypted_1 != encrypted_2
# But decryption should work correctly for each
assert decrypt_password(encrypted_1, user_id_1) == password
assert decrypt_password(encrypted_2, user_id_2) == password
assert encryptor_1.decrypt(encrypted_1) == password
assert encryptor_2.decrypt(encrypted_2) == password
def test_decrypt_with_wrong_user_id_fails(self):
"""Test that decryption fails with wrong user ID"""
password = "mailpassword123"
user_id = 1
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):
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 { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api';
import { X, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { ProviderWizard } from './ProviderWizard';
interface AddMailAccountModalProps {
account?: MailAccount | null;
onClose: () => void;
}
type WizardStep = 'provider' | 'form';
export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) {
const queryClient = useQueryClient();
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [autoDetecting, setAutoDetecting] = useState(false);
const [wizardStep, setWizardStep] = useState<WizardStep>(account ? 'form' : 'provider');
const [formData, setFormData] = useState<MailAccountCreate>({
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 () => {
if (!formData.username) {
alert('Please enter an email address first');
@@ -131,208 +147,237 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account Name
</label>
<input
type="text"
name="name"
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"
/>
{wizardStep === 'provider' && !account ? (
<ProviderWizard
onSelect={handleProviderSelect}
onManual={() => setWizardStep('form')}
/>
) : (
<div className="space-y-4">
{!account && (
<button
type="button"
onClick={handleAutoDetect}
disabled={autoDetecting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
onClick={() => setWizardStep('provider')}
className="text-sm text-blue-600 hover:text-blue-800 mb-2"
>
{autoDetecting ? 'Detecting...' : 'Auto-Detect'}
Back to provider selection
</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>
<label className="block text-sm font-medium text-gray-700 mb-1">
Host
Account Name
</label>
<input
type="text"
name="host"
value={formData.host}
name="name"
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="pop.gmail.com"
placeholder="My Email Account"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port
Email Address / Username
</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 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
type="button"
onClick={handleAutoDetect}
disabled={autoDetecting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
{autoDetecting ? 'Detecting...' : 'Auto-Detect'}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Emails Per Check
Password
</label>
<input
type="number"
name="max_emails_per_check"
value={formData.max_emails_per_check}
type="password"
name="password"
value={formData.password}
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"
placeholder={account ? 'Leave blank to keep current password' : 'Password'}
/>
</div>
</div>
{testStatus !== 'idle' && (
<div
className={`p-3 rounded-md flex items-start ${
testStatus === 'success'
? 'bg-green-50 border border-green-200'
: testStatus === 'error'
? 'bg-red-50 border border-red-200'
: 'bg-blue-50 border border-blue-200'
}`}
>
{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" />}
{testStatus === 'error' && <XCircle className="h-5 w-5 text-red-500 mr-2" />}
<span
className={`text-sm ${
<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="pop3_ssl">POP3 (SSL)</option>
<option value="imap">IMAP</option>
<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'
? 'text-green-700'
? 'bg-green-50 border border-green-200'
: testStatus === 'error'
? 'text-red-700'
: 'text-blue-700'
? 'bg-red-50 border border-red-200'
: 'bg-blue-50 border border-blue-200'
}`}
>
{testStatus === 'testing' ? 'Testing connection...' : testMessage}
</span>
</div>
)}
</div>
{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" />}
{testStatus === 'error' && <XCircle className="h-5 w-5 text-red-500 mr-2" />}
<span
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 className="bg-gray-50 px-6 py-4 flex items-center justify-between gap-3">
<button
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">
{wizardStep === 'form' && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between 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"
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"
>
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'}
Test Connection
</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>
)}
</form>
</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>
);
}