Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# ============================================
Expand Down
3 changes: 3 additions & 0 deletions backend/bulk_predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 2 additions & 2 deletions fastapi_backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down
Loading