File size: 7,726 Bytes
de745bf
 
 
 
 
 
 
 
 
 
 
 
b93f0d1
de745bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de745bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b93f0d1
 
 
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
203
204
205
206
207
208
209
210
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from contextlib import asynccontextmanager
import asyncio
import time

from .config import settings
from .api.routes import router
from .api.jobs import router as jobs_router
from .auth import license_middleware
from .utils.logger import setup_logging
from .db import repo

# Setup structured logging
setup_logging()

# Rate limiter
limiter = Limiter(key_func=get_remote_address)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Startup and shutdown events."""
    print(f"BRIDGE Modul - X v{settings.app_version} starting up...")
    # Verify Supabase connectivity at boot (best-effort, never fatal)
    if settings.supabase_configured:
        try:
            status = await repo.db_status()
            if status.get("reachable"):
                print(f"[DB] Supabase reachable β€” tables_ready={status.get('tables_ready')}")
                if not status.get("tables_ready"):
                    print("[DB] WARNING: run supabase/schema.sql in the Supabase SQL editor "
                          "to create the tables.")
            else:
                print(f"[DB] WARNING: Supabase not reachable β€” {status.get('detail')}")
        except Exception as exc:  # pragma: no cover - defensive
            print(f"[DB] WARNING: DB check failed β€” {exc}")
    else:
        print("[DB] Supabase not configured β€” persistence disabled (see .env.example)")

    # Port-25 egress preflight: mailbox verification cannot work without it, so
    # report it at boot instead of letting operators discover it from a wall of
    # "smtp_unreachable" results. Best-effort and never fatal.
    try:
        from .validator.egress import preflight_or_warn

        await asyncio.to_thread(preflight_or_warn)
    except Exception as exc:  # pragma: no cover - defensive
        print(f"[EGRESS] preflight skipped β€” {exc}")

    # Greylist retry-queue drain loop (in-process β€” the Space runs no Celery
    # beat, so without this the queue would fill forever and greylisted
    # mailboxes would stay permanently risky). Fail-open, never fatal.
    drain_task = None
    try:
        from .tasks.drain_loop import DrainLoop
        from .validator.engine import engine as _engine

        async def _prober(email: str, mx_host: str):
            return await _engine.mailbox.verify(email, mx_host)

        loop = DrainLoop(prober=_prober, interval_seconds=300)
        drain_task = asyncio.create_task(loop.start())
        print("[RETRY] drain loop started (every 300s)")
    except Exception as exc:  # pragma: no cover - defensive
        print(f"[RETRY] drain loop skipped β€” {exc}")

    # Warm the advisory model at startup: the first request otherwise pays the
    # torch + checkpoint load (measured 42.6s cold start) even though inference
    # now runs off the event loop. Fail-open and never fatal.
    try:
        from .ml.runtime import get_model_async

        model = await get_model_async()
        if model is not None and getattr(model, "loaded", False):
            print("[ML] advisory model preloaded")
        else:
            print("[ML] advisory model unavailable - deterministic mode")
    except Exception as exc:  # pragma: no cover - defensive
        print(f"[ML] preload skipped β€” {exc}")

    # Warm the LOCAL risk scorer at startup (Part B architecture): weights are
    # baked into the image (HF_HOME=/app/models), so this is a fast disk-cache
    # load (~seconds) with ZERO network. Without it, the first paying request
    # would pay the model load. Fail-open: a failed load trips the circuit
    # breaker inside the scorer and the heuristic takes over (spec Β§2.3).
    try:
        from .ml.risk_scorer import ai_scorer as _scorer

        if _scorer._inference_configured():
            await asyncio.to_thread(_scorer._ensure_loaded)
            st = _scorer.status()
            print(
                f"[AI] local risk scorer preloaded: model={st['model']} "
                f"loaded={st['model_loaded']} "
                f"circuit_open={st['circuit']['open']}"
            )
    except Exception as exc:  # pragma: no cover - defensive
        print(f"[AI] risk scorer preload skipped β€” {exc}")

    yield

    if drain_task is not None:
        drain_task.cancel()
    print("BRIDGE Modul - X shutting down...")


# Create FastAPI app
app = FastAPI(
    title=settings.app_name,
    description="""
BRIDGE Modul - X β€” 7-Layer Email Validation Engine.

Features:
- Layer 1 β€” RFC 5321/5322 Syntax Validation
- Layer 2 β€” DNS Domain Existence Check
- Layer 3 β€” MX Record Verification
- Layer 4 β€” Disposable/Role-Based/Free Provider Detection
- Layer 5 β€” Catch-All Domain Detection
- Layer 6 β€” SMTP Mailbox Verification (with proxy support)
- Layer 7 β€” Deliverability Scoring & Risk Assessment

Provider support: Gmail, Outlook, Yahoo, iCloud, ProtonMail, Zoho, 100K+ domains
    """,
    version=settings.app_version,
    docs_url="/docs",
    redoc_url="/redoc",
    lifespan=lifespan
)

# ---- Middleware -----------------------------------------------------
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    expose_headers=["X-Process-Time"]
)


@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    start_time = time.monotonic()
    response = await call_next(request)
    process_time = (time.monotonic() - start_time) * 1000
    response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
    return response


# Central Supabase JWT + license gate for paid API routes.
# /api/v1/health and /api/health stay open (HF + website productApi probe).
# No-op while LICENSE_ENFORCEMENT != "true" (demo mode). See app/auth.py.
@app.middleware("http")
async def license_gate(request: Request, call_next):
    return await license_middleware(request, call_next)


# Global error handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal server error",
            "detail": str(exc) if settings.debug else "Contact support"
        }
    )


# ---- Include Routes -------------------------------------------------
app.include_router(router, prefix="/api/v1")
# Server-driven bulk jobs (POST /api/v1/jobs): the engine finalizes the run
# with DB heartbeats so a closed browser tab can no longer orphan it.
app.include_router(jobs_router, prefix="/api/v1")


# ---- Health alias for the website productApi probe -------------------
# productApi tries GET <backend>/api/health then /health. The versioned
# route lives at /api/v1/health; this alias keeps the standard probe path
# working (stays open via the license_gate allow-list in app/auth.py).
@app.get("/api/health", tags=["System"])
async def api_health_alias():
    import time as _time

    return {
        "status": "healthy",
        "app": settings.app_name,
        "version": settings.app_version,
        "timestamp": _time.time(),
    }


# ---- Root -----------------------------------------------------------
@app.get("/")
async def root():
    return {
        "app": settings.app_name,
        "version": settings.app_version,
        "docs": "/docs",
        "health": "/api/v1/health"
    }