diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..528caa3 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""App package""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..f86627d --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API package""" diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..89a666b --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core package""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..5aba009 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,98 @@ +""" +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 + + +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" + ) + + # Application + APP_NAME: str = "POP3 Forwarder SaaS" + APP_VERSION: str = "2.0.0" + DEBUG: bool = False + API_V1_PREFIX: str = "/api/v1" + + # Server + HOST: str = "0.0.0.0" + PORT: int = 8000 + + # Database + DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder" + DATABASE_POOL_SIZE: int = 20 + DATABASE_MAX_OVERFLOW: int = 10 + + # Security + SECRET_KEY: str = "change-this-to-a-secure-random-secret-key-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Encryption (for storing POP3/IMAP credentials) + ENCRYPTION_KEY: str = "change-this-to-a-secure-encryption-key" + + # OAuth2 - Google + GOOGLE_CLIENT_ID: Optional[str] = None + GOOGLE_CLIENT_SECRET: Optional[str] = None + GOOGLE_REDIRECT_URI: str = "http://localhost:3000/auth/callback/google" + + # CORS + CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] + + # Stripe Payment + STRIPE_API_KEY: Optional[str] = None + STRIPE_WEBHOOK_SECRET: Optional[str] = None + STRIPE_PUBLISHABLE_KEY: Optional[str] = None + + # Subscription Tiers + TIER_FREE_MAX_ACCOUNTS: int = 1 + TIER_BASIC_MAX_ACCOUNTS: int = 5 + TIER_PRO_MAX_ACCOUNTS: int = 20 + TIER_ENTERPRISE_MAX_ACCOUNTS: int = 100 + + # Email Processing + MAX_EMAILS_PER_RUN: int = 50 + CHECK_INTERVAL_MINUTES: int = 5 + THROTTLE_EMAILS_PER_MINUTE: int = 10 + + # Redis (for Celery and caching) + REDIS_URL: str = "redis://localhost:6379/0" + + # Celery + CELERY_BROKER_URL: str = "redis://localhost:6379/0" + CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0" + + # Apprise (notifications) + APPRISE_ENABLED: bool = True + + # Logging + LOG_LEVEL: str = "INFO" + + # Admin + ADMIN_EMAIL: Optional[str] = None + ADMIN_PASSWORD: Optional[str] = None + + # Mail Server Presets + MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json" + + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def assemble_cors_origins(cls, v: str | List[str]) -> List[str]: + """Parse CORS origins from environment variable""" + if isinstance(v, str): + return [i.strip() for i in v.split(",")] + return v + + +# Global settings instance +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..cac3093 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,40 @@ +""" +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 + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, +) + +# Create async session factory +async_session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + +# Base class for models +Base = declarative_base() + + +async def get_db() -> AsyncSession: + """Dependency for getting async database session""" + async with async_session_maker() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..01f1a10 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,133 @@ +""" +Authentication dependencies for FastAPI. +""" +from typing import Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import decode_token +from app.models.database_models import User + +# OAuth2 scheme for token authentication +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/api/v1/auth/login", auto_error=False) +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) +) -> User: + """ + Get current authenticated user from JWT token. + Supports both OAuth2 password bearer and HTTP Bearer authentication. + """ + # Get token from either source + auth_token = token or (credentials.credentials if credentials else None) + + if not auth_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Decode token + payload = decode_token(auth_token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Verify token type + token_type = payload.get("type") + if token_type != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Get user ID from token + user_id: Optional[int] = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Fetch user from database + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive" + ) + + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """Get current active user""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + return current_user + + +async def get_current_superuser( + current_user: User = Depends(get_current_user), +) -> User: + """Get current superuser""" + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +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 + } + + async def check_tier(current_user: User = Depends(get_current_active_user)) -> User: + user_tier_level = tier_hierarchy.get(current_user.subscription_tier.value, 0) + required_tier_level = tier_hierarchy.get(required_tier, 0) + + if user_tier_level < required_tier_level: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"This feature requires {required_tier} subscription or higher" + ) + + return current_user + + return check_tier diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d798cd8 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,113 @@ +""" +Security utilities for encryption, hashing, and token generation. +""" +import secrets +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +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 +import base64 + +from app.core.config import settings + + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Generate password hash""" + return pwd_context.hash(password) + + +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) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: Dict[str, Any]) -> str: + """Create JWT refresh token""" + 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) + 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]) + return payload + except JWTError: + return None + + +def generate_random_token(length: int = 32) -> str: + """Generate a secure random token""" + return secrets.token_urlsafe(length) + + +class CredentialEncryption: + """Handles encryption/decryption of sensitive credentials (POP3/IMAP passwords)""" + + def __init__(self, key: Optional[str] = None): + """ + Initialize encryption with a key. + If no key provided, uses the one from settings. + """ + if key is None: + key = settings.ENCRYPTION_KEY + + # Derive a proper Fernet key from the provided key + kdf = PBKDF2( + algorithm=hashes.SHA256(), + length=32, + salt=b'pop3_forwarder_salt', # In production, use unique salt per user + iterations=100000, + ) + 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') + + def decrypt(self, encrypted_text: str) -> str: + """Decrypt a base64-encoded ciphertext""" + encrypted_bytes = base64.b64decode(encrypted_text.encode('utf-8')) + decrypted = self.fernet.decrypt(encrypted_bytes) + return decrypted.decode('utf-8') + + +# Global encryption instance +credential_encryptor = CredentialEncryption() + + +def encrypt_credential(credential: str) -> str: + """Convenience function to encrypt a credential""" + return credential_encryptor.encrypt(credential) + + +def decrypt_credential(encrypted_credential: str) -> str: + """Convenience function to decrypt a credential""" + return credential_encryptor.decrypt(encrypted_credential) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c28f715 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,12 @@ +"""Models package""" +from app.models.database_models import ( + 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" +] diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py new file mode 100644 index 0000000..8113430 --- /dev/null +++ b/backend/app/models/database_models.py @@ -0,0 +1,328 @@ +""" +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 +) +from sqlalchemy.orm import relationship +import enum + +from app.core.database import Base + + +class SubscriptionTier(str, enum.Enum): + """Subscription tier levels""" + FREE = "free" + BASIC = "basic" + PRO = "pro" + ENTERPRISE = "enterprise" + + +class MailProtocol(str, enum.Enum): + """Supported mail protocols""" + POP3 = "pop3" + POP3_SSL = "pop3_ssl" + IMAP = "imap" + IMAP_SSL = "imap_ssl" + + +class AccountStatus(str, enum.Enum): + """Mail account status""" + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + TESTING = "testing" + + +class NotificationChannel(str, enum.Enum): + """Notification channel types""" + EMAIL = "email" + TELEGRAM = "telegram" + WEBHOOK = "webhook" + SLACK = "slack" + DISCORD = "discord" + + +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 + full_name = Column(String(255)) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + + # OAuth + google_id = Column(String(255), unique=True, index=True, nullable=True) + oauth_provider = Column(String(50), nullable=True) + + # Subscription + subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE) + subscription_status = Column(String(50), default="active") # active, canceled, past_due + 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) + 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") + + +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) + + # Account details + name = Column(String(255), nullable=False) # User-friendly name + email_address = Column(String(255), nullable=False) + + # Server configuration + protocol = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL) + host = Column(String(255), nullable=False) + port = Column(Integer, nullable=False) + use_ssl = Column(Boolean, default=True) + use_tls = Column(Boolean, default=False) + + # Credentials (encrypted) + username = Column(String(255), nullable=False) + encrypted_password = Column(Text, nullable=False) + + # Forwarding destination + forward_to = Column(String(255), nullable=False) + + # Status and settings + status = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE) + is_enabled = Column(Boolean, default=True) + check_interval_minutes = Column(Integer, default=5) + max_emails_per_check = Column(Integer, default=50) + delete_after_forward = Column(Boolean, default=True) + + # Auto-detection metadata + provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX" + auto_detected = Column(Boolean, default=False) + + # Statistics + total_emails_processed = Column(Integer, default=0) + total_emails_failed = Column(Integer, default=0) + last_check_at = Column(DateTime, nullable=True) + last_successful_check_at = Column(DateTime, nullable=True) + last_error_at = Column(DateTime, nullable=True) + last_error_message = Column(Text, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="mail_accounts") + 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'), + ) + + +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) + + # Run details + started_at = Column(DateTime, default=datetime.utcnow, nullable=False) + completed_at = Column(DateTime, nullable=True) + duration_seconds = Column(Float, nullable=True) + + # Results + emails_fetched = Column(Integer, default=0) + emails_forwarded = Column(Integer, default=0) + emails_failed = Column(Integer, default=0) + + # Status + status = Column(String(50), default="running") # running, completed, failed + error_message = Column(Text, nullable=True) + + # Relationships + mail_account = relationship("MailAccount", back_populates="processing_runs") + + # Indexes + __table_args__ = ( + Index('idx_account_started', 'mail_account_id', 'started_at'), + ) + + +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) + + # Log details + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + level = Column(String(20), nullable=False) # INFO, WARNING, ERROR + message = Column(Text, nullable=False) + + # Email metadata (if applicable) + email_subject = Column(String(500), nullable=True) + email_from = Column(String(255), nullable=True) + email_size_bytes = Column(Integer, nullable=True) + + # Status + success = Column(Boolean, default=True) + error_details = Column(JSON, nullable=True) + + # Relationships + user = relationship("User", back_populates="logs") + + # Indexes + __table_args__ = ( + Index('idx_user_timestamp', 'user_id', 'timestamp'), + Index('idx_account_timestamp', 'mail_account_id', 'timestamp'), + ) + + +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) + + # Channel details + channel = Column(SQLEnum(NotificationChannel), nullable=False) + is_enabled = Column(Boolean, default=True) + + # Channel-specific configuration (stored as JSON) + config = Column(JSON, nullable=False) + # Examples: + # EMAIL: {"address": "user@example.com"} + # TELEGRAM: {"bot_token": "xxx", "chat_id": "yyy"} + # WEBHOOK: {"url": "https://example.com/webhook", "headers": {...}} + + # Notification preferences + notify_on_errors = Column(Boolean, default=True) + notify_on_success = Column(Boolean, default=False) + notify_threshold = Column(Integer, default=3) # Notify after N consecutive errors + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="notifications") + + # Indexes + __table_args__ = ( + Index('idx_user_channel', 'user_id', 'channel'), + ) + + +class MailServerPreset(Base): + """Predefined mail server configurations for common providers""" + __tablename__ = "mail_server_presets" + + id = Column(Integer, primary_key=True, index=True) + + # Provider info + provider_name = Column(String(100), unique=True, nullable=False, index=True) + provider_domain = Column(String(255), nullable=False) # e.g., "gmail.com" + + # Server configurations (can have multiple protocols) + configs = Column(JSON, nullable=False) + # Example: + # { + # "pop3_ssl": {"host": "pop.gmail.com", "port": 995, "ssl": true}, + # "imap_ssl": {"host": "imap.gmail.com", "port": 993, "ssl": true} + # } + + # Metadata + is_verified = Column(Boolean, default=False) + popularity_score = Column(Integer, default=0) # For sorting recommendations + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + +class SubscriptionPlan(Base): + """Available subscription plans and their features""" + __tablename__ = "subscription_plans" + + id = Column(Integer, primary_key=True, index=True) + + # Plan details + tier = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + + # Pricing + price_monthly = Column(Float, nullable=False) + price_yearly = Column(Float, nullable=True) + + # Stripe integration + stripe_price_id_monthly = Column(String(255), nullable=True) + stripe_price_id_yearly = Column(String(255), nullable=True) + + # Features/Limits + max_mail_accounts = Column(Integer, nullable=False) + max_emails_per_day = Column(Integer, nullable=False) + check_interval_minutes = Column(Integer, nullable=False) + support_level = Column(String(50), default="community") # community, email, priority + features = Column(JSON, nullable=True) # Additional features as JSON + + # Status + is_active = Column(Boolean, default=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + +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_email = Column(String(255), nullable=True) # Cached for deleted users + ip_address = Column(String(45), nullable=True) # IPv4 or IPv6 + + # What + action = Column(String(100), nullable=False, index=True) + resource_type = Column(String(50), nullable=True) + resource_id = Column(Integer, nullable=True) + + # Details + details = Column(JSON, nullable=True) + status = Column(String(20), default="success") # success, failure + + # When + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Indexes + __table_args__ = ( + Index('idx_user_action', 'user_id', 'action'), + Index('idx_timestamp_action', 'timestamp', 'action'), + ) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..2eb48f4 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,304 @@ +""" +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 +from enum import Enum + + +# Enums matching database models +class SubscriptionTier(str, Enum): + FREE = "free" + BASIC = "basic" + PRO = "pro" + ENTERPRISE = "enterprise" + + +class MailProtocol(str, Enum): + POP3 = "pop3" + POP3_SSL = "pop3_ssl" + IMAP = "imap" + IMAP_SSL = "imap_ssl" + + +class AccountStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + TESTING = "testing" + + +class NotificationChannel(str, Enum): + EMAIL = "email" + TELEGRAM = "telegram" + WEBHOOK = "webhook" + SLACK = "slack" + DISCORD = "discord" + + +# User Schemas +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: Optional[str] = None + + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + email: Optional[EmailStr] = None + + +class UserResponse(UserBase): + id: int + is_active: bool + subscription_tier: SubscriptionTier + subscription_status: str + created_at: datetime + + class Config: + from_attributes = True + + +class UserDetailResponse(UserResponse): + google_id: Optional[str] = None + oauth_provider: Optional[str] = None + stripe_customer_id: Optional[str] = None + subscription_expires_at: Optional[datetime] = None + last_login_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Authentication Schemas +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseModel): + sub: Optional[int] = None + exp: Optional[int] = None + type: Optional[str] = None + + +class GoogleAuthRequest(BaseModel): + code: str + redirect_uri: str + + +# Mail Account Schemas +class MailAccountBase(BaseModel): + name: str = Field(..., max_length=255) + email_address: EmailStr + protocol: MailProtocol = MailProtocol.POP3_SSL + host: str = Field(..., max_length=255) + port: int = Field(..., gt=0, lt=65536) + use_ssl: bool = True + use_tls: bool = False + username: str = Field(..., max_length=255) + forward_to: EmailStr + 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) + delete_after_forward: bool = True + + +class MailAccountCreate(MailAccountBase): + password: str # Will be encrypted before storage + + +class MailAccountUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=255) + password: Optional[str] = None + forward_to: Optional[EmailStr] = 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) + delete_after_forward: Optional[bool] = None + + +class MailAccountResponse(MailAccountBase): + id: int + user_id: int + status: AccountStatus + provider_name: Optional[str] = None + auto_detected: bool + total_emails_processed: int + total_emails_failed: int + last_check_at: Optional[datetime] = None + last_successful_check_at: Optional[datetime] = None + last_error_at: Optional[datetime] = None + last_error_message: Optional[str] = None + created_at: datetime + updated_at: datetime + + # Don't expose password or username in responses + password: str = Field(exclude=True, default="") + username: str = Field(exclude=True, default="") + + class Config: + from_attributes = True + + +class MailAccountTestRequest(BaseModel): + """Test connection to mail server""" + host: str + port: int + protocol: MailProtocol + username: str + password: str + use_ssl: bool = True + use_tls: bool = False + + +class MailAccountTestResponse(BaseModel): + success: bool + message: str + details: Optional[Dict[str, Any]] = None + + +class MailAccountAutoDetectRequest(BaseModel): + """Auto-detect mail server settings""" + email_address: EmailStr + + +class MailAccountAutoDetectResponse(BaseModel): + success: bool + suggestions: List[Dict[str, Any]] + + +# Processing Run Schemas +class ProcessingRunResponse(BaseModel): + id: int + mail_account_id: int + started_at: datetime + completed_at: Optional[datetime] = None + duration_seconds: Optional[float] = None + emails_fetched: int + emails_forwarded: int + emails_failed: int + status: str + error_message: Optional[str] = None + + class Config: + from_attributes = True + + +# Processing Log Schemas +class ProcessingLogResponse(BaseModel): + id: int + timestamp: datetime + level: str + message: str + email_subject: Optional[str] = None + email_from: Optional[str] = None + success: bool + + class Config: + from_attributes = True + + +# Notification Config Schemas +class NotificationConfigBase(BaseModel): + channel: NotificationChannel + is_enabled: bool = True + config: Dict[str, Any] + notify_on_errors: bool = True + notify_on_success: bool = False + notify_threshold: int = Field(default=3, gt=0, le=100) + + +class NotificationConfigCreate(NotificationConfigBase): + pass + + +class NotificationConfigUpdate(BaseModel): + is_enabled: Optional[bool] = None + config: Optional[Dict[str, Any]] = None + notify_on_errors: Optional[bool] = None + notify_on_success: Optional[bool] = None + notify_threshold: Optional[int] = Field(None, gt=0, le=100) + + +class NotificationConfigResponse(NotificationConfigBase): + id: int + user_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Subscription Schemas +class SubscriptionPlanResponse(BaseModel): + id: int + tier: SubscriptionTier + name: str + description: Optional[str] = None + price_monthly: float + price_yearly: Optional[float] = None + max_mail_accounts: int + max_emails_per_day: int + check_interval_minutes: int + support_level: str + features: Optional[Dict[str, Any]] = None + is_active: bool + + class Config: + from_attributes = True + + +class SubscriptionCheckoutRequest(BaseModel): + tier: SubscriptionTier + billing_period: str = Field(..., pattern="^(monthly|yearly)$") + success_url: str + cancel_url: str + + +class SubscriptionCheckoutResponse(BaseModel): + checkout_url: str + session_id: str + + +# Statistics Schemas +class AccountStatistics(BaseModel): + total_accounts: int + active_accounts: int + inactive_accounts: int + error_accounts: int + total_emails_processed: int + total_emails_failed: int + + +class ProcessingStatistics(BaseModel): + last_24h_processed: int + last_24h_failed: int + last_7d_processed: int + last_7d_failed: int + success_rate: float + + +class DashboardStatistics(BaseModel): + account_stats: AccountStatistics + processing_stats: ProcessingStatistics + recent_runs: List[ProcessingRunResponse] + recent_errors: List[ProcessingLogResponse] + + +# Mail Server Preset Schemas +class MailServerPresetResponse(BaseModel): + id: int + provider_name: str + provider_domain: str + configs: Dict[str, Any] + is_verified: bool + + class Config: + from_attributes = True diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e372604 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package""" diff --git a/backend/app/services/mail_processor.py b/backend/app/services/mail_processor.py new file mode 100644 index 0000000..62e1d82 --- /dev/null +++ b/backend/app/services/mail_processor.py @@ -0,0 +1,507 @@ +""" +Mail processing service for fetching and forwarding emails. +Supports both POP3 and IMAP protocols with secure connections. +""" +import asyncio +import poplib +import smtplib +import ssl +from email import parser +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formatdate, make_msgid +from typing import List, Dict, Any, Optional, Tuple +from datetime import datetime +import logging +from aioimaplib import aioimaplib + +from app.models.database_models import MailAccount, MailProtocol +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class MailConnectionError(Exception): + """Raised when unable to connect to mail server""" + pass + + +class MailAuthenticationError(Exception): + """Raised when authentication fails""" + pass + + +class MailFetchError(Exception): + """Raised when fetching emails fails""" + pass + + +class MailForwardError(Exception): + """Raised when forwarding email fails""" + pass + + +class MailProcessor: + """Handles mail fetching and forwarding operations""" + + def __init__(self, account: MailAccount, decrypted_password: str): + self.account = account + self.password = decrypted_password + + async def test_connection(self) -> Tuple[bool, str]: + """ + Test connection to mail server. + Returns (success, message) + """ + try: + if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]: + return await self._test_pop3_connection() + else: + return await self._test_imap_connection() + except Exception as e: + logger.error(f"Connection test failed: {e}") + return False, str(e) + + async def _test_pop3_connection(self) -> Tuple[bool, str]: + """Test POP3 connection""" + try: + loop = asyncio.get_event_loop() + + # Run blocking POP3 operations in thread pool + def connect_pop3(): + if self.account.protocol == MailProtocol.POP3_SSL: + context = ssl.create_default_context() + pop_conn = poplib.POP3_SSL( + self.account.host, + self.account.port, + context=context, + timeout=10 + ) + else: + pop_conn = poplib.POP3( + self.account.host, + self.account.port, + timeout=10 + ) + + # Try authentication + pop_conn.user(self.account.username) + pop_conn.pass_(self.password) + + # Get mailbox stats + message_count, mailbox_size = pop_conn.stat() + + pop_conn.quit() + return message_count, mailbox_size + + message_count, mailbox_size = await loop.run_in_executor(None, connect_pop3) + + return True, f"Connection successful. {message_count} messages in mailbox." + + except poplib.error_proto as e: + error_msg = str(e) + if "authentication" in error_msg.lower() or "auth" in error_msg.lower(): + return False, f"Authentication failed: {error_msg}" + return False, f"POP3 protocol error: {error_msg}" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + async def _test_imap_connection(self) -> Tuple[bool, str]: + """Test IMAP connection""" + try: + # Create IMAP client + if self.account.protocol == MailProtocol.IMAP_SSL: + imap_client = aioimaplib.IMAP4_SSL( + host=self.account.host, + port=self.account.port, + timeout=10 + ) + else: + imap_client = aioimaplib.IMAP4( + host=self.account.host, + port=self.account.port, + timeout=10 + ) + + await imap_client.wait_hello_from_server() + + # Authenticate + response = await imap_client.login(self.account.username, self.password) + + if response.result != 'OK': + return False, f"Authentication failed: {response.lines}" + + # Select inbox + await imap_client.select('INBOX') + + # Get message count + response = await imap_client.search('ALL') + message_ids = response.lines[0].split() + message_count = len(message_ids) + + await imap_client.logout() + + return True, f"Connection successful. {message_count} messages in mailbox." + + except Exception as e: + return False, f"IMAP connection failed: {str(e)}" + + async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]: + """ + Fetch emails from the mail server. + Returns list of raw email data. + """ + max_count = max_count or self.account.max_emails_per_check + + if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]: + return await self._fetch_pop3_emails(max_count) + else: + return await self._fetch_imap_emails(max_count) + + async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]: + """Fetch emails via POP3""" + emails = [] + + try: + loop = asyncio.get_event_loop() + + def fetch_pop3(): + # Connect + if self.account.protocol == MailProtocol.POP3_SSL: + context = ssl.create_default_context() + pop_conn = poplib.POP3_SSL( + self.account.host, + self.account.port, + context=context, + timeout=30 + ) + else: + pop_conn = poplib.POP3( + self.account.host, + self.account.port, + timeout=30 + ) + + # Authenticate + pop_conn.user(self.account.username) + pop_conn.pass_(self.password) + + # Get message count + num_messages = len(pop_conn.list()[1]) + logger.info(f"Found {num_messages} messages for account {self.account.id}") + + fetched_emails = [] + messages_to_delete = [] + + # Fetch emails (limited by max_count) + for i in range(1, min(num_messages + 1, max_count + 1)): + try: + response, lines, octets = pop_conn.retr(i) + email_data = b'\r\n'.join(lines) + fetched_emails.append(email_data) + messages_to_delete.append(i) + logger.info(f"Retrieved message {i} from account {self.account.id}") + except Exception as e: + logger.error(f"Error retrieving message {i}: {e}") + + # Delete messages if configured + if self.account.delete_after_forward: + for msg_id in messages_to_delete: + try: + pop_conn.dele(msg_id) + except Exception as e: + logger.error(f"Error deleting message {msg_id}: {e}") + + pop_conn.quit() + return fetched_emails + + emails = await loop.run_in_executor(None, fetch_pop3) + + except Exception as e: + logger.error(f"Error fetching POP3 emails: {e}") + raise MailFetchError(f"POP3 fetch error: {str(e)}") + + return emails + + async def _fetch_imap_emails(self, max_count: int) -> List[bytes]: + """Fetch emails via IMAP""" + emails = [] + + try: + # Create IMAP client + if self.account.protocol == MailProtocol.IMAP_SSL: + imap_client = aioimaplib.IMAP4_SSL( + host=self.account.host, + port=self.account.port, + timeout=30 + ) + else: + imap_client = aioimaplib.IMAP4( + 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') + + # Search for all messages + 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}") + + # Fetch each message + for msg_id in message_ids: + try: + 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: + # Find the email content + 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'*'): + email_data = line + break + + if email_data: + emails.append(email_data) + + # Mark as seen if deleting after forward + if self.account.delete_after_forward: + await imap_client.store(msg_id, '+FLAGS', '\\Deleted') + + except Exception as e: + logger.error(f"Error fetching message {msg_id}: {e}") + + # Expunge deleted messages + if self.account.delete_after_forward: + await imap_client.expunge() + + await imap_client.logout() + + except Exception as e: + logger.error(f"Error fetching IMAP emails: {e}") + raise MailFetchError(f"IMAP fetch error: {str(e)}") + + return emails + + @staticmethod + async def forward_email( + email_data: bytes, + source_account_name: str, + destination: str, + smtp_config: Dict[str, Any] + ) -> bool: + """ + Forward an email to the destination address. + + Args: + email_data: Raw email bytes + source_account_name: Name of source account for labeling + destination: Destination email address + smtp_config: SMTP configuration dict with keys: + - host: SMTP host + - port: SMTP port + - username: SMTP username + - password: SMTP password + - use_tls: Whether to use STARTTLS + + Returns: + True if successful, False otherwise + """ + try: + loop = asyncio.get_event_loop() + + def send_email(): + # Parse the email + msg = parser.BytesParser().parsebytes(email_data) + + # Create forwarding message + forward_msg = MIMEMultipart('mixed') + forward_msg['From'] = smtp_config['username'] + forward_msg['To'] = destination + forward_msg['Date'] = formatdate(localtime=True) + forward_msg['Message-ID'] = make_msgid() + + # Preserve original subject with prefix + original_subject = msg.get('Subject', 'No Subject') + forward_msg['Subject'] = f"[Fwd from {source_account_name}] {original_subject}" + + # Add original headers + header_info = f"Originally from: {msg.get('From', 'Unknown')}\n" + header_info += f"Original Date: {msg.get('Date', 'Unknown')}\n" + header_info += f"Original Subject: {original_subject}\n" + header_info += f"Source Account: {source_account_name}\n" + header_info += "-" * 50 + "\n\n" + + # Get email body + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + body = part.get_payload(decode=True).decode('utf-8', errors='ignore') + break + else: + payload = msg.get_payload(decode=True) + if payload: + 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')) + + # Send via SMTP + 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) + + try: + server.login(smtp_config['username'], smtp_config['password']) + server.send_message(forward_msg) + logger.info(f"Successfully forwarded email to {destination}") + return True + finally: + try: + server.quit() + except: + pass + + return await loop.run_in_executor(None, send_email) + + except Exception as e: + logger.error(f"Error forwarding email: {e}") + raise MailForwardError(f"Forward error: {str(e)}") + + +class MailServerAutoDetect: + """Auto-detect mail server settings based on email domain""" + + # Common mail server configurations + KNOWN_PROVIDERS = { + "gmail.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}, + "imap_ssl": {"host": "outlook.office365.com", "port": 993}, + }, + "hotmail.com": { + "name": "Hotmail", + "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}, + "imap_ssl": {"host": "imap.gmx.com", "port": 993}, + }, + "gmx.de": { + "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}, + "imap_ssl": {"host": "imap.web.de", "port": 993}, + }, + "t-online.de": { + "name": "T-Online", + "pop3_ssl": {"host": "pop.t-online.de", "port": 995}, + "imap_ssl": {"host": "imap.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}, + }, + } + + @classmethod + def detect(cls, email_address: str) -> List[Dict[str, Any]]: + """ + Detect mail server settings for an email address. + Returns list of possible configurations. + """ + domain = email_address.split('@')[-1].lower() + + suggestions = [] + + # Check if we have a known provider + if domain in cls.KNOWN_PROVIDERS: + provider = cls.KNOWN_PROVIDERS[domain] + + # Add POP3 SSL suggestion + if "pop3_ssl" in provider: + suggestions.append({ + "protocol": "pop3_ssl", + "provider_name": provider["name"], + "host": provider["pop3_ssl"]["host"], + "port": provider["pop3_ssl"]["port"], + "use_ssl": True, + "use_tls": False, + }) + + # Add IMAP SSL suggestion + if "imap_ssl" in provider: + suggestions.append({ + "protocol": "imap_ssl", + "provider_name": provider["name"], + "host": provider["imap_ssl"]["host"], + "port": provider["imap_ssl"]["port"], + "use_ssl": True, + "use_tls": False, + }) + else: + # Generic suggestions based on common patterns + suggestions.extend([ + { + "protocol": "pop3_ssl", + "provider_name": "Generic", + "host": f"pop.{domain}", + "port": 995, + "use_ssl": True, + "use_tls": False, + }, + { + "protocol": "pop3_ssl", + "provider_name": "Generic", + "host": f"pop3.{domain}", + "port": 995, + "use_ssl": True, + "use_tls": False, + }, + { + "protocol": "imap_ssl", + "provider_name": "Generic", + "host": f"imap.{domain}", + "port": 993, + "use_ssl": True, + "use_tls": False, + }, + { + "protocol": "imap_ssl", + "provider_name": "Generic", + "host": f"mail.{domain}", + "port": 993, + "use_ssl": True, + "use_tls": False, + }, + ]) + + return suggestions diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..5ee152c --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utils package""" diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..db181bc --- /dev/null +++ b/backend/app/workers/__init__.py @@ -0,0 +1 @@ +"""Workers package""" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a93fbf6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,56 @@ +# Core Framework +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 + +# Database +sqlalchemy==2.0.25 +alembic==1.13.1 +psycopg2-binary==2.9.9 +asyncpg==0.29.0 + +# Authentication +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +authlib==1.3.0 +httpx==0.26.0 + +# Payment Processing +stripe==7.11.0 + +# Email & Mail Processing +aiosmtplib==3.0.1 +aiohttp==3.9.1 +aioimaplib==1.0.1 +email-validator==2.1.0.post1 + +# Job Queue & Cache +celery==5.3.6 +redis==5.0.1 + +# Security & Encryption +cryptography==42.0.0 + +# Notifications +apprise==1.7.1 + +# Monitoring & Logging +prometheus-client==0.19.0 +python-json-logger==2.0.7 + +# Development & Testing +pytest==7.4.4 +pytest-asyncio==0.23.3 +pytest-cov==4.1.0 +httpx==0.26.0 +faker==22.6.0 + +# Utilities +python-dotenv==1.0.0 +schedule==1.2.0 +tenacity==8.2.3 + +# Legacy support (for migration) +poplib3==0.0.4