File size: 8,449 Bytes
de745bf 6c516b6 de745bf 6c516b6 de745bf 6c516b6 de745bf 6c516b6 de745bf 6c516b6 de745bf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | 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()
|