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()