from urllib.parse import urlsplit from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from typing import List def _is_https_origin(value: str) -> bool: try: parsed = urlsplit(value) _ = parsed.port except ValueError: return False return ( parsed.scheme == "https" and bool(parsed.hostname) and parsed.username is None and parsed.password is None and parsed.path in {"", "/"} and not parsed.query and not parsed.fragment ) class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # App app_environment: str = "development" app_name: str = "BRIDGE Modul - X" app_version: str = "3.1.0" debug: bool = False secret_key: str = "change-me-in-production" # Server host: str = "0.0.0.0" port: int = 8000 workers: int = 4 # Database database_url: str = "sqlite+aiosqlite:///./emailval.db" # Redis redis_url: str = "redis://localhost:6379/0" redis_cache_ttl: int = 86400 # 24 hours # CORS cors_origins: List[str] = [ "http://localhost:5173", "http://localhost:3000", "http://127.0.0.1:5173", ] # Validation smtp_timeout: int = 10 dns_timeout: int = 5 # Public resolvers used instead of the host's own resolver config. A stale # gateway list (dead hotspot IPs) makes every lookup burn the full timeout # and fail — see dns_check.build_resolver. dns_nameservers: list[str] = ["1.1.1.1", "8.8.8.8"] # Bulk validation only routes through Celery on hosts that actually run a # worker. The HF Space runs uvicorn alone (Dockerfile CMD), so a reachable # Redis must not by itself select the Celery branch — jobs would sit in # the queue forever. Opt in on the VPS deployment. celery_bulk_enabled: bool = False max_bulk_emails: int = 10000 rate_limit_per_minute: int = 60 bulk_sync_chunk_size: int = 50 # Process this many emails at a time in sync mode # Proxy use_proxies: bool = False proxy_list: List[str] = [] # Mailbox verification provider (Layer 5 catch-all + Layer 6 SMTP). # The engine is FULLY AUTONOMOUS: mailbox verdicts come from its own SMTP # probing engine — never from a third-party verification API. "local" # forces the built-in engine; "remote" forces the remote SMTP worker; # "auto" prefers the remote worker when SMTP_WORKER_URL is set and falls # back to the built-in engine otherwise. mailbox_provider: str = "auto" # auto | local | remote # Public HELO/MX identity used in the SMTP dialogue. A real, resolvable # hostname with matching PTR improves acceptance by strict receivers. smtp_helo_domain: str = "validator.local" smtp_mail_from: str = "probe@validator.local" # Remote SMTP probe worker (spec §3.3): orchestrator and SMTP worker are # separate deployments. Point this at a port-25-capable host running this # same image (its POST /api/v1/smtp/probe endpoints) and every mailbox # verdict flows through it. Shared secret via SMTP_WORKER_TOKEN. smtp_worker_url: str = "" smtp_worker_token: str = "" smtp_worker_timeout: int = 15 # Circuit breaker: after this many consecutive direct-SMTP connection # failures (blocked egress), fail fast for `smtp_cooldown_seconds` instead # of burning the full timeout on every address. smtp_fail_threshold: int = 3 smtp_cooldown_seconds: int = 300 # Catch-all is a domain property — cache the verdict for 72h so a bulk list # does not re-probe the same domain for every address. catch_all_ttl_seconds: int = 72 * 3600 # DNS/MX answers change slowly; a definitive mailbox verdict is stable for # about a day. Transient failures are never cached regardless of TTL. dns_cache_ttl_seconds: int = 24 * 3600 # Per-MX-host SMTP session cap and global concurrency for bulk runs. max_connections_per_host: int = 2 bulk_concurrency: int = 20 # --- DNS-over-HTTPS worker (spec §3.2) --- # MX resolution over free public DoH resolvers (Google + Cloudflare) with a # dual-resolver consistency check. Legacy dnspython stays as fallback. doh_enabled: bool = True doh_timeout: int = 5 # --- AI Risk Scorer (spec §3.4 — LOCAL inference, zero external credits) --- # Part B architecture migration: the Hugging Face Inference API path was # REMOVED. The model id below names a public HF Hub checkpoint whose # weights are pulled once (free, unmetered) and then run in-process via a # transformers pipeline — no token, no inference credits, no remote calls. # A DistilBERT-class NLI checkpoint is the spec-default (§5.3): same # feature set, ~4x lower CPU latency than bart-large-mnli on 2 vCPU. # Empty disables inference entirely (the rule-based heuristic then scores # every ambiguous address at zero marginal cost). Configured, never # hard-coded. ai_scorer_model: str = "typeform/distilbert-base-uncased-mnli" ai_scorer_timeout: int = 12 # Zero-shot mode: candidate labels ARE the risk taxonomy — works with any # local NLI checkpoint (e.g. typeform/distilbert-base-uncased-mnli or # facebook/bart-large-mnli if CPU budget allows). ai_scorer_zero_shot: bool = True # Master switch: False forces heuristic-only scoring (no model load at all). ai_scorer_local: bool = True # §2.2 cache aggressively: a repeated PII-free feature string never # re-invokes the pipeline inside this TTL window. 0 disables the cache. ai_score_cache_ttl_seconds: int = 3600 ai_circuit_fail_threshold: int = 3 ai_circuit_cooldown_seconds: int = 300 # --- DistilBERT email-risk signal (advisory, unknown domains only) --- # OFF by default: with the flag off the engine's behaviour is identical to # the pre-ML build. The model is a public HF repo trained on the engine's # own blocklists (see app/ml/). Promotion from advisory to blocking requires # measured DISPOSABLE precision >= 0.98 (app/ml/train.py reports it). # Hub downloads are free and unmetered; no token is required or used. ml_disposable_enabled: bool = False ml_model_repo: str = "" ml_batch_size: int = 64 ml_score_penalty: int = 5 # Run-quality monitor (report item 10): alert when >= this share of a bulk # run comes back risky/unknown/error — the signature of a systemic failure # (dead provider, blocked egress, engine regression). 0 disables it. risky_rate_alert_threshold: float = 0.5 # Celery celery_broker_url: str = "redis://localhost:6379/1" celery_result_backend: str = "redis://localhost:6379/2" # Supabase (Postgres + PostgREST) supabase_url: str = "" supabase_anon_key: str = "" supabase_service_role_key: str = "" enable_db_persistence: bool = True # Central billing gate: "true" enforces Supabase JWT + active license # (modul-x directly, via combo-validate-send, or via combo-full-bridge) # on every paid API route. Anything else = open demo mode. license_enforcement: str = "" @model_validator(mode="after") def validate_production_settings(self) -> "Settings": if self.app_environment.strip().casefold() != "production": return self problems = [] if self.debug: problems.append("DEBUG must be false") if self.license_enforcement.strip().casefold() != "true": problems.append("LICENSE_ENFORCEMENT must be true") if not self.supabase_configured: problems.append("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required") elif not _is_https_origin(self.supabase_url): problems.append("SUPABASE_URL must be an HTTPS URL") if not self.cors_origins or not all( _is_https_origin(origin) for origin in self.cors_origins ): problems.append("CORS_ORIGINS must contain only HTTPS origins") if problems: raise ValueError("Unsafe production configuration: " + "; ".join(problems)) return self @property def supabase_configured(self) -> bool: return bool(self.supabase_url and self.supabase_service_role_key) settings = Settings()