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"])
+3 -2
View File
@@ -1,4 +1,5 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -13,7 +14,7 @@ router = APIRouter()
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get overall system statistics (admin only)"""
@@ -32,5 +33,5 @@ async def get_admin_stats(
return {
"total_users": total_users,
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs
"total_processing_runs": total_runs,
}
+24 -35
View File
@@ -1,6 +1,7 @@
"""
Authentication endpoints (login, register, OAuth).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,41 +12,37 @@ import logging
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash
from app.models.database_models import User, SubscriptionTier
from app.models.schemas import (
Token, UserCreate, UserResponse, GoogleAuthRequest
)
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_in: UserCreate,
db: AsyncSession = Depends(get_db)
):
@router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user with email and password"""
# Check if user exists
result = await db.execute(
select(User).where(User.email == user_in.email)
)
result = await db.execute(select(User).where(User.email == user_in.email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)
# Create new user
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password) if user_in.password else None,
hashed_password=(
get_password_hash(user_in.password) if user_in.password else None
),
subscription_tier=SubscriptionTier.FREE,
is_active=True
is_active=True,
)
db.add(user)
@@ -59,15 +56,12 @@ async def register(
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
):
"""Login with email and password"""
# Get user
result = await db.execute(
select(User).where(User.email == form_data.username)
)
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
@@ -88,8 +82,7 @@ async def login(
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
# Update last login
@@ -106,8 +99,7 @@ async def login(
@router.post("/google", response_model=Token)
async def google_oauth(
auth_request: GoogleAuthRequest,
db: AsyncSession = Depends(get_db)
auth_request: GoogleAuthRequest, db: AsyncSession = Depends(get_db)
):
"""
Authenticate with Google OAuth2.
@@ -116,24 +108,21 @@ async def google_oauth(
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code,
redirect_uri=auth_request.redirect_uri
code=auth_request.code, redirect_uri=auth_request.redirect_uri
)
if not user_info.get('verified_email'):
if not user_info.get("verified_email"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google"
detail="Email not verified with Google",
)
email = user_info['email']
google_id = user_info['google_id']
email = user_info["email"]
google_id = user_info["google_id"]
# Check if user exists
result = await db.execute(
select(User).where(
(User.email == email) | (User.google_id == google_id)
)
select(User).where((User.email == email) | (User.google_id == google_id))
)
user = result.scalar_one_or_none()
@@ -151,12 +140,12 @@ async def google_oauth(
# Create new user
user = User(
email=email,
full_name=user_info.get('full_name'),
full_name=user_info.get("full_name"),
google_id=google_id,
oauth_provider="google",
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.utcnow()
last_login_at=datetime.utcnow(),
)
db.add(user)
+31 -31
View File
@@ -1,4 +1,5 @@
"""Mail account management endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,9 +10,13 @@ from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential, decrypt_credential
from app.models.database_models import User, MailAccount
from app.models.schemas import (
MailAccountCreate, MailAccountResponse, MailAccountUpdate,
MailAccountTestRequest, MailAccountTestResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectResponse
MailAccountCreate,
MailAccountResponse,
MailAccountUpdate,
MailAccountTestRequest,
MailAccountTestResponse,
MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse,
)
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings
@@ -19,11 +24,13 @@ from app.core.config import settings
router = APIRouter()
@router.post("", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=MailAccountResponse, status_code=status.HTTP_201_CREATED
)
async def create_mail_account(
account_in: MailAccountCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create a new mail account"""
@@ -45,7 +52,7 @@ async def create_mail_account(
if len(existing_accounts) >= max_accounts:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Account limit reached. Upgrade your subscription to add more accounts."
detail=f"Account limit reached. Upgrade your subscription to add more accounts.",
)
# Encrypt password
@@ -64,10 +71,11 @@ 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)
@@ -80,7 +88,7 @@ async def create_mail_account(
@router.get("", response_model=List[MailAccountResponse])
async def list_mail_accounts(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all mail accounts for current user"""
result = await db.execute(
@@ -96,21 +104,19 @@ async def list_mail_accounts(
async def get_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Get a specific mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
return account
@@ -121,28 +127,28 @@ async def update_mail_account(
account_id: int,
account_update: MailAccountUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
# Update fields
update_data = account_update.dict(exclude_unset=True)
if "password" in update_data:
update_data["encrypted_password"] = encrypt_credential(update_data.pop("password"))
update_data["encrypted_password"] = encrypt_credential(
update_data.pop("password")
)
for field, value in update_data.items():
setattr(account, field, value)
@@ -157,21 +163,19 @@ async def update_mail_account(
async def delete_mail_account(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Delete a mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail account not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
await db.delete(account)
@@ -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)
@@ -219,6 +220,5 @@ async def auto_detect_mail_settings(
suggestions = MailServerAutoDetect.detect(detect_request.email_address)
return MailAccountAutoDetectResponse(
success=len(suggestions) > 0,
suggestions=suggestions
success=len(suggestions) > 0, suggestions=suggestions
)
+10 -8
View File
@@ -1,4 +1,5 @@
"""Notification configuration endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,23 +9,24 @@ from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig
from app.models.schemas import (
NotificationConfigCreate, NotificationConfigResponse, NotificationConfigUpdate
NotificationConfigCreate,
NotificationConfigResponse,
NotificationConfigUpdate,
)
router = APIRouter()
@router.post("", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED
)
async def create_notification_config(
config_in: NotificationConfigCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Create notification configuration"""
config = NotificationConfig(
user_id=current_user.id,
**config_in.dict()
)
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
db.add(config)
await db.commit()
await db.refresh(config)
@@ -34,7 +36,7 @@ async def create_notification_config(
@router.get("", response_model=List[NotificationConfigResponse])
async def list_notification_configs(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""List all notification configurations"""
result = await db.execute(
+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,
}
+3 -2
View File
@@ -1,4 +1,5 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,7 @@ router = APIRouter()
@router.get("/me", response_model=UserDetailResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
current_user: User = Depends(get_current_active_user),
):
"""Get current user profile"""
return current_user
@@ -22,7 +23,7 @@ async def get_current_user_profile(
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Update current user profile"""
if user_update.email:
+9 -5
View File
@@ -2,6 +2,7 @@
Application configuration using Pydantic settings.
Supports environment variables and .env files.
"""
from typing import Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import PostgresDsn, field_validator, ValidationInfo
@@ -11,10 +12,7 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
# Application
@@ -28,7 +26,9 @@ class Settings(BaseSettings):
PORT: int = 8000
# Database
DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
DATABASE_URL: str = (
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
)
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@@ -46,6 +46,10 @@ class Settings(BaseSettings):
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"]
+1
View File
@@ -1,6 +1,7 @@
"""
Database configuration and session management.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.core.config import settings
+12 -15
View File
@@ -1,9 +1,14 @@
"""
Authentication dependencies for FastAPI.
"""
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
from fastapi.security import (
OAuth2PasswordBearer,
HTTPBearer,
HTTPAuthorizationCredentials,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -19,7 +24,7 @@ http_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(http_bearer),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> User:
"""
Get current authenticated user from JWT token.
@@ -75,8 +80,7 @@ async def get_current_user(
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
return user
@@ -88,8 +92,7 @@ async def get_current_active_user(
"""Get current active user"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
@@ -100,8 +103,7 @@ async def get_current_superuser(
"""Get current superuser"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return current_user
@@ -111,12 +113,7 @@ def check_subscription_tier(required_tier: str):
Dependency factory to check if user has required subscription tier.
Returns a dependency function.
"""
tier_hierarchy = {
"free": 0,
"basic": 1,
"pro": 2,
"enterprise": 3
}
tier_hierarchy = {"free": 0, "basic": 1, "pro": 2, "enterprise": 3}
async def check_tier(current_user: User = Depends(get_current_active_user)) -> User:
user_tier_level = tier_hierarchy.get(current_user.subscription_tier.value, 0)
@@ -125,7 +122,7 @@ def check_subscription_tier(required_tier: str):
if user_tier_level < required_tier_level:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"This feature requires {required_tier} subscription or higher"
detail=f"This feature requires {required_tier} subscription or higher",
)
return current_user
+4 -1
View File
@@ -1,6 +1,7 @@
"""
Security middleware for adding security headers and CSRF protection.
"""
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
@@ -25,7 +26,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Strict Transport Security (HTTPS only)
# Note: Only enable in production with HTTPS
if request.url.hostname not in ["localhost", "127.0.0.1"]:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
# Content Security Policy (adjust based on frontend needs)
csp = (
+27 -17
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
@@ -81,35 +92,34 @@ class CredentialEncryption:
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
+4 -3
View File
@@ -1,6 +1,7 @@
"""
Main FastAPI application.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -14,7 +15,7 @@ from app.api.v1.api import api_router
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ def create_application() -> FastAPI:
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
openapi_url="/api/openapi.json",
)
# Security middleware (add before CORS)
@@ -54,7 +55,7 @@ def create_application() -> FastAPI:
return {
"message": "POP3 Forwarder SaaS API",
"version": settings.APP_VERSION,
"docs": "/api/docs"
"docs": "/api/docs",
}
@app.get("/health")
+25 -6
View File
@@ -1,12 +1,31 @@
"""Models package"""
from app.models.database_models import (
User, MailAccount, ProcessingRun, ProcessingLog,
NotificationConfig, MailServerPreset, SubscriptionPlan, AuditLog,
SubscriptionTier, MailProtocol, AccountStatus, NotificationChannel
User,
MailAccount,
ProcessingRun,
ProcessingLog,
NotificationConfig,
MailServerPreset,
SubscriptionPlan,
AuditLog,
SubscriptionTier,
MailProtocol,
AccountStatus,
NotificationChannel,
)
__all__ = [
"User", "MailAccount", "ProcessingRun", "ProcessingLog",
"NotificationConfig", "MailServerPreset", "SubscriptionPlan", "AuditLog",
"SubscriptionTier", "MailProtocol", "AccountStatus", "NotificationChannel"
"User",
"MailAccount",
"ProcessingRun",
"ProcessingLog",
"NotificationConfig",
"MailServerPreset",
"SubscriptionPlan",
"AuditLog",
"SubscriptionTier",
"MailProtocol",
"AccountStatus",
"NotificationChannel",
]
+134 -33
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,11 +69,14 @@ class NotificationChannel(str, enum.Enum):
class User(Base):
"""User model - represents a user account"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=True) # Nullable for OAuth-only users
hashed_password = Column(
String(255), nullable=True
) # Nullable for OAuth-only users
full_name = Column(String(255))
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
@@ -63,28 +87,41 @@ class User(Base):
# Subscription
subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE)
subscription_status = Column(String(50), default="active") # active, canceled, past_due
subscription_status = Column(
String(50), default="active"
) # active, canceled, past_due
stripe_customer_id = Column(String(255), unique=True, nullable=True)
stripe_subscription_id = Column(String(255), unique=True, nullable=True)
subscription_expires_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
last_login_at = Column(DateTime, nullable=True)
# Relationships
mail_accounts = relationship("MailAccount", back_populates="user", cascade="all, delete-orphan")
notifications = relationship("NotificationConfig", back_populates="user", cascade="all, delete-orphan")
logs = relationship("ProcessingLog", back_populates="user", cascade="all, delete-orphan")
mail_accounts = relationship(
"MailAccount", back_populates="user", cascade="all, delete-orphan"
)
notifications = relationship(
"NotificationConfig", back_populates="user", cascade="all, delete-orphan"
)
logs = relationship(
"ProcessingLog", back_populates="user", cascade="all, delete-orphan"
)
class MailAccount(Base):
"""Mail account configuration (POP3/IMAP)"""
__tablename__ = "mail_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Account details
name = Column(String(255), nullable=False) # User-friendly name
@@ -104,6 +141,9 @@ class MailAccount(Base):
# 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)
@@ -125,25 +165,32 @@ class MailAccount(Base):
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
user = relationship("User", back_populates="mail_accounts")
processing_runs = relationship("ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan")
processing_runs = relationship(
"ProcessingRun", back_populates="mail_account", cascade="all, delete-orphan"
)
# Indexes
__table_args__ = (
Index('idx_user_email', 'user_id', 'email_address'),
Index('idx_status_enabled', 'status', 'is_enabled'),
Index("idx_user_email", "user_id", "email_address"),
Index("idx_status_enabled", "status", "is_enabled"),
)
class ProcessingRun(Base):
"""Records of email processing runs for each mail account"""
__tablename__ = "processing_runs"
id = Column(Integer, primary_key=True, index=True)
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
mail_account_id = Column(
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
)
# Run details
started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
@@ -163,19 +210,24 @@ class ProcessingRun(Base):
mail_account = relationship("MailAccount", back_populates="processing_runs")
# Indexes
__table_args__ = (
Index('idx_account_started', 'mail_account_id', 'started_at'),
)
__table_args__ = (Index("idx_account_started", "mail_account_id", "started_at"),)
class ProcessingLog(Base):
"""Detailed logs of individual email processing attempts"""
__tablename__ = "processing_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
mail_account_id = Column(Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False)
processing_run_id = Column(Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
mail_account_id = Column(
Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False
)
processing_run_id = Column(
Integer, ForeignKey("processing_runs.id", ondelete="CASCADE"), nullable=True
)
# Log details
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
@@ -196,17 +248,20 @@ class ProcessingLog(Base):
# Indexes
__table_args__ = (
Index('idx_user_timestamp', 'user_id', 'timestamp'),
Index('idx_account_timestamp', 'mail_account_id', 'timestamp'),
Index("idx_user_timestamp", "user_id", "timestamp"),
Index("idx_account_timestamp", "mail_account_id", "timestamp"),
)
class NotificationConfig(Base):
"""User notification channel configurations"""
__tablename__ = "notification_configs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Channel details
channel = Column(SQLEnum(NotificationChannel), nullable=False)
@@ -226,19 +281,20 @@ class NotificationConfig(Base):
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
user = relationship("User", back_populates="notifications")
# Indexes
__table_args__ = (
Index('idx_user_channel', 'user_id', 'channel'),
)
__table_args__ = (Index("idx_user_channel", "user_id", "channel"),)
class MailServerPreset(Base):
"""Predefined mail server configurations for common providers"""
__tablename__ = "mail_server_presets"
id = Column(Integer, primary_key=True, index=True)
@@ -261,11 +317,14 @@ class MailServerPreset(Base):
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class SubscriptionPlan(Base):
"""Available subscription plans and their features"""
__tablename__ = "subscription_plans"
id = Column(Integer, primary_key=True, index=True)
@@ -287,7 +346,9 @@ class SubscriptionPlan(Base):
max_mail_accounts = Column(Integer, nullable=False)
max_emails_per_day = Column(Integer, nullable=False)
check_interval_minutes = Column(Integer, nullable=False)
support_level = Column(String(50), default="community") # community, email, priority
support_level = Column(
String(50), default="community"
) # community, email, priority
features = Column(JSON, nullable=True) # Additional features as JSON
# Status
@@ -295,17 +356,22 @@ class SubscriptionPlan(Base):
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class AuditLog(Base):
"""Audit trail for security and compliance"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
# Who
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
user_email = Column(String(255), nullable=True) # Cached for deleted users
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
@@ -323,6 +389,41 @@ class AuditLog(Base):
# Indexes
__table_args__ = (
Index('idx_user_action', 'user_id', 'action'),
Index('idx_timestamp_action', 'timestamp', 'action'),
Index("idx_user_action", "user_id", "action"),
Index("idx_timestamp_action", "timestamp", "action"),
)
class GmailCredential(Base):
"""Stores OAuth2 credentials for Gmail API access (per-user)"""
__tablename__ = "gmail_credentials"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
# 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")
+46
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"
@@ -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
@@ -148,6 +157,7 @@ class MailAccountResponse(MailAccountBase):
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
@@ -302,3 +313,38 @@ class MailServerPresetResponse(BaseModel):
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]
+30 -25
View File
@@ -1,6 +1,7 @@
"""
OAuth2 authentication service for Google and other providers.
"""
from typing import Dict, Any, Optional
from datetime import datetime
import httpx
@@ -26,14 +27,16 @@ class OAuthService:
"""Register Google OAuth2 provider"""
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
self.oauth.register(
name='google',
name="google",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'}
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
async def get_google_user_info(self, code: str, redirect_uri: str) -> Dict[str, Any]:
async def get_google_user_info(
self, code: str, redirect_uri: str
) -> Dict[str, Any]:
"""
Exchange Google authorization code for user information.
@@ -48,53 +51,55 @@ class OAuthService:
# Exchange code for token
async with httpx.AsyncClient() as client:
token_response = await client.post(
'https://oauth2.googleapis.com/token',
"https://oauth2.googleapis.com/token",
data={
'code': code,
'client_id': settings.GOOGLE_CLIENT_ID,
'client_secret': settings.GOOGLE_CLIENT_SECRET,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code'
}
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
logger.error(f"Google token exchange failed: {token_response.text}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code"
detail="Failed to exchange authorization code",
)
token_data = token_response.json()
access_token = token_data.get('access_token')
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No access token received"
detail="No access token received",
)
# Get user info
user_info_response = await client.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
headers={'Authorization': f'Bearer {access_token}'}
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if user_info_response.status_code != 200:
logger.error(f"Google user info fetch failed: {user_info_response.text}")
logger.error(
f"Google user info fetch failed: {user_info_response.text}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user information"
detail="Failed to get user information",
)
user_info = user_info_response.json()
return {
'email': user_info.get('email'),
'full_name': user_info.get('name'),
'google_id': user_info.get('id'),
'picture': user_info.get('picture'),
'verified_email': user_info.get('verified_email', False)
"email": user_info.get("email"),
"full_name": user_info.get("name"),
"google_id": user_info.get("id"),
"picture": user_info.get("picture"),
"verified_email": user_info.get("verified_email", False),
}
except HTTPException:
@@ -103,7 +108,7 @@ class OAuthService:
logger.error(f"OAuth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed"
detail="OAuth authentication failed",
)
@staticmethod
@@ -123,7 +128,7 @@ class OAuthService:
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
"token_type": "bearer",
}
+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
+176 -58
View File
@@ -2,6 +2,7 @@
Mail processing service for fetching and forwarding emails.
Supports both POP3 and IMAP protocols with secure connections.
"""
import asyncio
import poplib
import smtplib
@@ -23,21 +24,25 @@ logger = logging.getLogger(__name__)
class MailConnectionError(Exception):
"""Raised when unable to connect to mail server"""
pass
class MailAuthenticationError(Exception):
"""Raised when authentication fails"""
pass
class MailFetchError(Exception):
"""Raised when fetching emails fails"""
pass
class MailForwardError(Exception):
"""Raised when forwarding email fails"""
pass
@@ -75,13 +80,11 @@ class MailProcessor:
self.account.host,
self.account.port,
context=context,
timeout=10
timeout=10,
)
else:
pop_conn = poplib.POP3(
self.account.host,
self.account.port,
timeout=10
self.account.host, self.account.port, timeout=10
)
# Try authentication
@@ -112,15 +115,11 @@ class MailProcessor:
# Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host,
port=self.account.port,
timeout=10
host=self.account.host, port=self.account.port, timeout=10
)
else:
imap_client = aioimaplib.IMAP4(
host=self.account.host,
port=self.account.port,
timeout=10
host=self.account.host, port=self.account.port, timeout=10
)
await imap_client.wait_hello_from_server()
@@ -128,14 +127,14 @@ class MailProcessor:
# Authenticate
response = await imap_client.login(self.account.username, self.password)
if response.result != 'OK':
if response.result != "OK":
return False, f"Authentication failed: {response.lines}"
# Select inbox
await imap_client.select('INBOX')
await imap_client.select("INBOX")
# Get message count
response = await imap_client.search('ALL')
response = await imap_client.search("ALL")
message_ids = response.lines[0].split()
message_count = len(message_ids)
@@ -173,13 +172,11 @@ class MailProcessor:
self.account.host,
self.account.port,
context=context,
timeout=30
timeout=30,
)
else:
pop_conn = poplib.POP3(
self.account.host,
self.account.port,
timeout=30
self.account.host, self.account.port, timeout=30
)
# Authenticate
@@ -188,7 +185,9 @@ class MailProcessor:
# Get message count
num_messages = len(pop_conn.list()[1])
logger.info(f"Found {num_messages} messages for account {self.account.id}")
logger.info(
f"Found {num_messages} messages for account {self.account.id}"
)
fetched_emails = []
messages_to_delete = []
@@ -197,10 +196,12 @@ class MailProcessor:
for i in range(1, min(num_messages + 1, max_count + 1)):
try:
response, lines, octets = pop_conn.retr(i)
email_data = b'\r\n'.join(lines)
email_data = b"\r\n".join(lines)
fetched_emails.append(email_data)
messages_to_delete.append(i)
logger.info(f"Retrieved message {i} from account {self.account.id}")
logger.info(
f"Retrieved message {i} from account {self.account.id}"
)
except Exception as e:
logger.error(f"Error retrieving message {i}: {e}")
@@ -231,45 +232,43 @@ class MailProcessor:
# Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL:
imap_client = aioimaplib.IMAP4_SSL(
host=self.account.host,
port=self.account.port,
timeout=30
host=self.account.host, port=self.account.port, timeout=30
)
else:
imap_client = aioimaplib.IMAP4(
host=self.account.host,
port=self.account.port,
timeout=30
host=self.account.host, port=self.account.port, timeout=30
)
await imap_client.wait_hello_from_server()
await imap_client.login(self.account.username, self.password)
await imap_client.select('INBOX')
await imap_client.select("INBOX")
# Search for all messages
response = await imap_client.search('UNSEEN') # Only fetch unread
response = await imap_client.search("UNSEEN") # Only fetch unread
message_ids = response.lines[0].split()
# Limit to max_count
message_ids = message_ids[:max_count]
logger.info(f"Found {len(message_ids)} unread messages for account {self.account.id}")
logger.info(
f"Found {len(message_ids)} unread messages for account {self.account.id}"
)
# Fetch each message
for msg_id in message_ids:
try:
response = await imap_client.fetch(msg_id, '(RFC822)')
response = await imap_client.fetch(msg_id, "(RFC822)")
# Extract email data from response
email_data = None
for line in response.lines:
if isinstance(line, bytes) and b'RFC822' in line:
if isinstance(line, bytes) and b"RFC822" in line:
# Find the email content
start_idx = line.find(b'{')
start_idx = line.find(b"{")
if start_idx != -1:
# Email data is in the next parts
continue
elif isinstance(line, bytes) and not line.startswith(b'*'):
elif isinstance(line, bytes) and not line.startswith(b"*"):
email_data = line
break
@@ -278,7 +277,7 @@ class MailProcessor:
# Mark as seen if deleting after forward
if self.account.delete_after_forward:
await imap_client.store(msg_id, '+FLAGS', '\\Deleted')
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
except Exception as e:
logger.error(f"Error fetching message {msg_id}: {e}")
@@ -300,7 +299,7 @@ class MailProcessor:
email_data: bytes,
source_account_name: str,
destination: str,
smtp_config: Dict[str, Any]
smtp_config: Dict[str, Any],
) -> bool:
"""
Forward an email to the destination address.
@@ -327,15 +326,17 @@ class MailProcessor:
msg = parser.BytesParser().parsebytes(email_data)
# Create forwarding message
forward_msg = MIMEMultipart('mixed')
forward_msg['From'] = smtp_config['username']
forward_msg['To'] = destination
forward_msg['Date'] = formatdate(localtime=True)
forward_msg['Message-ID'] = make_msgid()
forward_msg = MIMEMultipart("mixed")
forward_msg["From"] = smtp_config["username"]
forward_msg["To"] = destination
forward_msg["Date"] = formatdate(localtime=True)
forward_msg["Message-ID"] = make_msgid()
# Preserve original subject with prefix
original_subject = msg.get('Subject', 'No Subject')
forward_msg['Subject'] = f"[Fwd from {source_account_name}] {original_subject}"
original_subject = msg.get("Subject", "No Subject")
forward_msg["Subject"] = (
f"[Fwd from {source_account_name}] {original_subject}"
)
# Add original headers
header_info = f"Originally from: {msg.get('From', 'Unknown')}\n"
@@ -349,26 +350,32 @@ class MailProcessor:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
body = part.get_payload(decode=True).decode(
"utf-8", errors="ignore"
)
break
else:
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode('utf-8', errors='ignore')
body = payload.decode("utf-8", errors="ignore")
# Combine header and body
full_body = header_info + body
forward_msg.attach(MIMEText(full_body, 'plain', 'utf-8'))
forward_msg.attach(MIMEText(full_body, "plain", "utf-8"))
# Send via SMTP
if smtp_config.get('use_tls', True):
server = smtplib.SMTP(smtp_config['host'], smtp_config['port'], timeout=30)
if smtp_config.get("use_tls", True):
server = smtplib.SMTP(
smtp_config["host"], smtp_config["port"], timeout=30
)
server.starttls()
else:
server = smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port'], timeout=30)
server = smtplib.SMTP_SSL(
smtp_config["host"], smtp_config["port"], timeout=30
)
try:
server.login(smtp_config['username'], smtp_config['password'])
server.login(smtp_config["username"], smtp_config["password"])
server.send_message(forward_msg)
logger.info(f"Successfully forwarded email to {destination}")
return True
@@ -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,14 +464,84 @@ 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
@@ -438,7 +550,7 @@ class MailServerAutoDetect:
Detect mail server settings for an email address.
Returns list of possible configurations.
"""
domain = email_address.split('@')[-1].lower()
domain = email_address.split("@")[-1].lower()
suggestions = []
@@ -448,28 +560,33 @@ class MailServerAutoDetect:
# Add POP3 SSL suggestion
if "pop3_ssl" in provider:
suggestions.append({
suggestions.append(
{
"protocol": "pop3_ssl",
"provider_name": provider["name"],
"host": provider["pop3_ssl"]["host"],
"port": provider["pop3_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
})
}
)
# Add IMAP SSL suggestion
if "imap_ssl" in provider:
suggestions.append({
suggestions.append(
{
"protocol": "imap_ssl",
"provider_name": provider["name"],
"host": provider["imap_ssl"]["host"],
"port": provider["imap_ssl"]["port"],
"use_ssl": True,
"use_tls": False,
})
}
)
else:
# Generic suggestions based on common patterns
suggestions.extend([
suggestions.extend(
[
{
"protocol": "pop3_ssl",
"provider_name": "Generic",
@@ -502,6 +619,7 @@ class MailServerAutoDetect:
"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
+80 -20
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
@@ -52,7 +62,7 @@ async def process_mail_account(account_id: int):
run = ProcessingRun(
mail_account_id=account.id,
started_at=datetime.utcnow(),
status="running"
status="running",
)
db.add(run)
await db.commit()
@@ -73,39 +83,83 @@ async def process_mail_account(account_id: int):
emails_forwarded = 0
emails_failed = 0
# Get SMTP config from environment or user settings
# TODO: Make this configurable per user in the database
# 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"
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
}
if not smtp_config["username"] or not smtp_config["password"]:
logger.error(f"SMTP credentials not configured for account {account.id}")
logger.error(
f"SMTP credentials not configured for account {account.id}"
)
run.status = "failed"
run.error_message = "SMTP credentials not configured"
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 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:
# 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 Exception as e:
logger.error(f"Error forwarding email: {e}")
except (GmailInjectionError, Exception) as e:
logger.error(f"Error delivering email: {e}")
emails_failed += 1
# Update run
@@ -139,14 +193,16 @@ async def process_mail_account(account_id: int):
logger.error(f"Error processing account {account_id}: {e}")
# Mark run as failed
if 'run' in locals():
if "run" in locals():
run.status = "failed"
run.error_message = str(e)
run.completed_at = datetime.utcnow()
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
run.duration_seconds = (
run.completed_at - run.started_at
).total_seconds()
# Update account error status
if 'account' in locals():
if "account" in locals():
account.status = AccountStatus.ERROR
account.last_error_at = datetime.utcnow()
account.last_error_message = str(e)
@@ -167,7 +223,9 @@ async def process_all_enabled_accounts():
select(MailAccount).where(
and_(
MailAccount.is_enabled == True,
MailAccount.status.in_([AccountStatus.ACTIVE, AccountStatus.TESTING])
MailAccount.status.in_(
[AccountStatus.ACTIVE, AccountStatus.TESTING]
),
)
)
)
@@ -180,7 +238,9 @@ async def process_all_enabled_accounts():
# Check if it's time to check this account
if account.last_check_at:
time_since_last_check = datetime.utcnow() - account.last_check_at
if time_since_last_check.total_seconds() < (account.check_interval_minutes * 60):
if time_since_last_check.total_seconds() < (
account.check_interval_minutes * 60
):
logger.debug(f"Skipping account {account.id} - not time yet")
continue
+6
View File
@@ -0,0 +1,6 @@
import sys
from pathlib import Path
# Ensure the backend directory is on sys.path so that `app` is importable
# when pytest is invoked from the backend/ directory (e.g., `cd backend && pytest tests/`).
sys.path.insert(0, str(Path(__file__).resolve().parent))
+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
+8 -1
View File
@@ -1,6 +1,7 @@
"""
Test configuration and fixtures.
"""
import pytest
import asyncio
from typing import AsyncGenerator, Generator
@@ -15,7 +16,9 @@ from app.models.database_models import User
from app.core.security import get_password_hash, create_access_token
# Test database URL (use different database for tests)
TEST_DATABASE_URL = settings.DATABASE_URL.replace("/pop3_forwarder", "/pop3_forwarder_test")
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
"/pop3_forwarder", "/pop3_forwarder_test"
)
# Note: event_loop fixture removed - pytest-asyncio provides this automatically
@@ -120,9 +123,11 @@ def admin_auth_headers(test_admin_user: User) -> dict:
# Factory fixtures for creating test data
@pytest.fixture
def user_factory(db_session: AsyncSession):
"""Factory for creating test users"""
async def _create_user(
email: str = None,
password: str = "testpassword123",
@@ -132,6 +137,7 @@ def user_factory(db_session: AsyncSession):
) -> User:
if email is None:
import uuid
email = f"test-{uuid.uuid4()}@example.com"
user = User(
@@ -166,6 +172,7 @@ def mail_account_factory(db_session: AsyncSession):
) -> MailAccount:
if username is None:
import uuid
username = f"test-{uuid.uuid4()}@example.com"
encrypted_password = encrypt_password(password, user_id)
+9 -2
View File
@@ -1,6 +1,7 @@
"""
Unit tests for configuration module.
"""
import pytest
from pydantic import ValidationError
from app.core.config import Settings
@@ -56,5 +57,11 @@ class TestConfigValidation:
ENCRYPTION_KEY="this-is-a-secure-32-character-key-for-encryption",
)
assert settings.SECRET_KEY == "this-is-a-secure-32-character-key-for-testing-secret"
assert settings.ENCRYPTION_KEY == "this-is-a-secure-32-character-key-for-encryption"
assert (
settings.SECRET_KEY
== "this-is-a-secure-32-character-key-for-testing-secret"
)
assert (
settings.ENCRYPTION_KEY
== "this-is-a-secure-32-character-key-for-encryption"
)
+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
+20 -12
View File
@@ -1,13 +1,13 @@
"""
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,
)
@@ -49,7 +49,7 @@ class TestJWT:
assert isinstance(token, str)
assert len(token) > 50
assert token.count('.') == 2 # JWT has 3 parts
assert token.count(".") == 2 # JWT has 3 parts
class TestEncryption:
@@ -60,7 +60,8 @@ class TestEncryption:
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
@@ -70,8 +71,9 @@ class TestEncryption:
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
@@ -81,15 +83,18 @@ class TestEncryption:
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"""
@@ -97,7 +102,10 @@ class TestEncryption:
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)
@@ -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,7 +147,23 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button>
</div>
{wizardStep === 'provider' && !account ? (
<ProviderWizard
onSelect={handleProviderSelect}
onManual={() => setWizardStep('form')}
/>
) : (
<div className="space-y-4">
{!account && (
<button
type="button"
onClick={() => setWizardStep('provider')}
className="text-sm text-blue-600 hover:text-blue-800 mb-2"
>
Back to provider selection
</button>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account Name
@@ -199,7 +231,9 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
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>
@@ -278,6 +312,14 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</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 ${
@@ -305,8 +347,10 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</div>
)}
</div>
)}
</div>
{wizardStep === 'form' && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between gap-3">
<button
type="button"
@@ -333,6 +377,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button>
</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>
);
}