diff --git a/backend/api.py b/backend/api.py index 9cdaa75e..248dee0f 100644 --- a/backend/api.py +++ b/backend/api.py @@ -826,6 +826,7 @@ def heuristic_url_is_malicious(url): tld = host.rsplit(".", 1)[-1] if "." in host else "" return tld in SUSPICIOUS_TLDS +MAX_MESSAGE_LENGTH = int(os.getenv("MAX_MESSAGE_LENGTH", 5000)) MAX_MESSAGE_LENGTH = settings.max_message_length OUTPUT_DIR = BASE_DIR / "output" @@ -1211,6 +1212,25 @@ def predict(): text = data.get("text") input_type = data.get("type", "message") + if text is None or (isinstance(text, str) and not text.strip()): + with open(LOG_FILE, "a") as f: + f.write(f"WARNING: No text provided at {__import__('datetime').datetime.now()}\n") + return jsonify({"error": "No text provided"}), 400 + + if not isinstance(text, str): + return jsonify({ + "error": f"'text' must be a string, got {type(text).__name__}" + }), 400 + + + # Maximum-length validation before any vectorization/inference work. + if len(text) > MAX_MESSAGE_LENGTH: + return jsonify({ + "error": ( + f"'text' exceeds maximum length of {MAX_MESSAGE_LENGTH} " + f"characters (got {len(text)})" + ) + }), 400 # Read the live serving objects through the shared state so a # POST /reload-model hot-swap is picked up here without a restart # (#973). One snapshot per request keeps the model, vectorizer and @@ -2252,6 +2272,107 @@ def imap_connect(): ) +@app.route("/imap/status", methods=["GET"]) +@validate_request +@validate_internal_request +def imap_status(): + username = _require_username() + if not username: + return jsonify({"error": "Missing X-User-Username header"}), 401 + conn_row = imap_store.get_connection(username) + if not conn_row: + return jsonify({"connected": False}) + + return jsonify({ + "connected": True, + "host": conn_row["host"], + "imap_username": conn_row["imap_username"], + "scan_interval_minutes": conn_row["scan_interval_minutes"], + "consent_given_at": conn_row["consent_given_at"], + "last_scan_at": conn_row["last_scan_at"], + }) + + +@app.route("/imap/schedule", methods=["PUT"]) +@validate_request +@validate_internal_request +def imap_schedule(): + username = _require_username() + if not username: + return jsonify({"error": "Missing X-User-Username header"}), 401 + data = request.get_json(silent=True) or {} + scan_interval_minutes = data.get("scan_interval_minutes") + + if scan_interval_minutes not in imap_store.ALLOWED_INTERVALS: + return jsonify({"error": f"scan_interval_minutes must be one of {imap_store.ALLOWED_INTERVALS}"}), 400 + + if not imap_store.get_connection(username): + return jsonify({"error": "No connected inbox found for this account"}), 404 + + imap_store.update_schedule(username, scan_interval_minutes) + _schedule_user_job(username, scan_interval_minutes) + return jsonify({"message": "Scan schedule updated", "scan_interval_minutes": scan_interval_minutes}) + + +@app.route("/imap/disconnect", methods=["POST"]) +@validate_request +@validate_internal_request +def imap_disconnect(): + username = _require_username() + if not username: + return jsonify({"error": "Missing X-User-Username header"}), 401 + if not imap_store.get_connection(username): + return jsonify({"error": "No connected inbox found for this account"}), 404 + + job_id = f"imap_scan_{username}" + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + imap_store.delete_connection(username) + return jsonify({"message": "Inbox disconnected and stored credentials removed."}) + + +@app.route("/imap/scan-now", methods=["POST"]) +@validate_request +@validate_internal_request +def imap_scan_now(): + username = _require_username() + if not username: + return jsonify({"error": "Missing X-User-Username header"}), 401 + conn_row = imap_store.get_connection(username) + if not conn_row: + return jsonify({"error": "No connected inbox found for this account"}), 404 + + try: + password = decrypt_secret(conn_row["encrypted_password"]) + except CredentialEncryptionError as e: + return jsonify({"error": str(e)}), 500 + + try: + emails = imap_connector.fetch_imap_emails( + conn_row["host"], conn_row["port"], conn_row["imap_username"], password, limit=50 + ) + scan_results = scan_emails_with_model(emails) + imap_store.save_scan_results(username, scan_results["emails"]) + imap_store.update_last_scan(username) + return jsonify(scan_results) + except imap_connector.ImapAuthError as e: + return jsonify({"error": f"IMAP authentication failed: {e}"}), 401 + except Exception as e: + return jsonify({"error": f"Email scan execution failed: {e}"}), 500 + + +@app.route("/imap/scan-results", methods=["GET"]) +@validate_request +@validate_internal_request +def imap_scan_results(): + username = _require_username() + if not username: + return jsonify({"error": "Missing X-User-Username header"}), 401 + limit = request.args.get("limit", default=100, type=int) + history = imap_store.get_scan_history(username, limit=limit) + return jsonify({"results": history}) + + # ============================================ # MAIN # ============================================ diff --git a/backend/bulk_predict.py b/backend/bulk_predict.py index a4b90d3a..a4c35d54 100644 --- a/backend/bulk_predict.py +++ b/backend/bulk_predict.py @@ -114,6 +114,9 @@ def _extract_rows(file): return None, "Empty file uploaded." try: + content_bytes = file.read() + content_str = content_bytes.decode("utf-8", errors="replace") + text_wrapper = io.StringIO(content_str) text_wrapper = io.TextIOWrapper(file.stream, encoding="utf-8", errors="replace") except Exception: return None, "Failed to read uploaded file." diff --git a/fastapi_backend/main.py b/fastapi_backend/main.py index 683bdb70..64770e84 100644 --- a/fastapi_backend/main.py +++ b/fastapi_backend/main.py @@ -5,7 +5,7 @@ import numpy as np from pathlib import Path from fastapi import FastAPI, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from fastapi.middleware.cors import CORSMiddleware from backend.xai_service import XAIService from backend.config import FRONTEND_URL, BASE_URL, PORT @@ -114,7 +114,7 @@ async def log_requests_middleware(request: Request, call_next): # ── Request schema ──────────────────────────────────────────────────────────── class PredictIn(BaseModel): - text: str + text: str = Field(..., max_length=5000, description="Input query string") type: str # ── Prediction route ──────────────────────────────────────────────────────────