Add backend foundation: database models, security, and mail processing service
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""App package"""
|
||||
@@ -0,0 +1 @@
|
||||
"""API package"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Core package"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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'),
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Services package"""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Utils package"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Workers package"""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user