diff --git a/.env b/.env index 1d44286e25..fa6a18a25b 100644 --- a/.env +++ b/.env @@ -40,6 +40,10 @@ POSTGRES_PASSWORD=changethis SENTRY_DSN= +# Enrichr (optional — blocks disposable emails on signup) +# Get a free key at https://enrichrapi.dev (1,000 calls/month free) +ENRICHR_API_KEY= + # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend DOCKER_IMAGE_FRONTEND=frontend diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b626e..8e2bde816b 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -12,6 +12,7 @@ ) from app.core.config import settings from app.core.security import get_password_hash, verify_password +from app.enrichr import is_disposable_email from app.models import ( Item, Message, @@ -143,10 +144,17 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: @router.post("/signup", response_model=UserPublic) -def register_user(session: SessionDep, user_in: UserRegister) -> Any: +async def register_user(session: SessionDep, user_in: UserRegister) -> Any: """ Create new user without the need to be logged in. """ + # Block disposable/throwaway email addresses before touching the database. + # Requires ENRICHR_API_KEY in .env — skipped silently if not set. + if await is_disposable_email(user_in.email): + raise HTTPException( + status_code=422, + detail="Disposable email addresses are not allowed. Please use your real email.", + ) user = crud.get_user_by_email(session=session, email=user_in.email) if user: raise HTTPException( diff --git a/backend/app/enrichr.py b/backend/app/enrichr.py new file mode 100644 index 0000000000..72ae170791 --- /dev/null +++ b/backend/app/enrichr.py @@ -0,0 +1,60 @@ +""" +Enrichr — email validation utility +Blocks disposable addresses before they hit your database. + +Setup: add ENRICHR_API_KEY to your .env +Get a free key at https://enrichrapi.dev (1,000 calls/month free) +""" + +import os +from typing import Any + +import httpx + +_BASE = os.getenv("ENRICHR_BASE_URL", "https://enrichrapi.dev") + + +async def validate_email(email: str) -> dict[str, Any] | None: + """ + Validate an email address via Enrichr. + + Returns None if ENRICHR_API_KEY is not set (graceful degradation). + Returns None on any network error so signup is never blocked by a failed API call. + + Response fields: + valid (bool) — passes format + MX check + format_ok (bool) — RFC-5322 format valid + mx_ok (bool) — domain has MX records + disposable (bool) — known throwaway provider + normalized (str) — lowercased, plus-removed canonical form + domain (str) — email domain + """ + key = os.getenv("ENRICHR_API_KEY") + if not key: + return None + + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post( + f"{_BASE}/v1/enrich/email", + headers={"X-Api-Key": key}, + json={"email": email}, + ) + if not resp.is_success: + return None + return resp.json().get("data") + except Exception: + return None + + +async def is_disposable_email(email: str) -> bool: + """ + Returns True if the email is from a known disposable/throwaway provider. + Safe to call in API routes — returns False on any network error. + + Usage: + if await is_disposable_email(user_in.email): + raise HTTPException(status_code=422, detail="Disposable email addresses are not allowed.") + """ + result = await validate_email(email) + return result.get("disposable", False) if result else False