feat: scan history browser, user-scoped viewer tokens, export fixes, email fixes (v1.6.20)

- Scan history browser (history.js, GET /api/db/sessions, get_sessions(),
  get_session_items(ref_scan_id)) — review any past session without rescanning
- User-scoped viewer tokens (#34) — scope by individual employee across M365
  and GWS; autocomplete from Accounts list; dual-email support
- Fix: GWS scan never marked finished (end_scan → finish_scan) and emitted
  wrong SSE event (scan_done → google_scan_done), excluding GWS items from all
  exports
- Fix: file scan begin_scan called with wrong keyword args (TypeError swallowed),
  so local/SMB items were never written to DB
- Fix: Graph sendMail reported failure on success — _post() now returns {} on
  empty 202 response instead of raising JSONDecodeError
- Fix: Graph error hidden behind generic "No SMTP host" message when both Graph
  and SMTP were unavailable
- Fix: Gmail vs Google Workspace SMTP error messages distinguished by username
  domain; Workspace errors point to admin console, not personal security settings
- Docs: update README, MANUAL-EN, MANUAL-DA, CLAUDE.md, TODO.md, CHANGELOG.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Sonnet 4.6 committed 2026-04-18 13:57:54 +02:00
1 parent e64d7eb958
commit c9aab19a97
24 files changed
+753 -70

No files matched your search

+17 -1
View File
@@ -70,6 +70,13 @@ def db_scans():
return jsonify(_get_db().scans_list())
@bp.route("/api/db/sessions")
def db_sessions():
"""List scan sessions (grouped concurrent scans), newest first."""
if not DB_OK: return jsonify([])
return jsonify(_get_db().get_sessions())
@bp.route("/api/db/subject", methods=["POST"])
def db_subject_lookup():
"""Find all items containing a given CPR number.
@@ -154,13 +161,22 @@ def db_flagged_items():
from flask import session as _session
scope = _session.get("viewer_scope", {})
role_filt = scope.get("role", "") if isinstance(scope, dict) else ""
items = _get_db().get_session_items()
# user may be a list of emails (current) or a legacy single string
raw_user = scope.get("user", "") if isinstance(scope, dict) else ""
if isinstance(raw_user, list):
user_filt = set(e.lower() for e in raw_user if e)
else:
user_filt = {raw_user.lower()} if raw_user else set()
ref_scan_id = request.args.get("ref", type=int)
items = _get_db().get_session_items(ref_scan_id=ref_scan_id)
# Normalise JSON-encoded columns the same way scan_engine does for SSE cards
import json as _json
out = []
for row in items:
if role_filt and row.get("role", "") != role_filt:
continue
if user_filt and (row.get("account_id", "") or "").lower() not in user_filt:
continue
row["special_category"] = _json.loads(row.get("special_category") or "[]") if isinstance(row.get("special_category"), str) else row.get("special_category", [])
row["exif"] = _json.loads(row.get("exif_json") or "{}") if isinstance(row.get("exif_json"), str) else row.get("exif", {})
row.pop("exif_json", None)
+61 -10
View File
@@ -164,6 +164,12 @@ def smtp_test():
use_tls = bool(saved.get("use_tls", True)) and not use_ssl
if not host:
if graph_error_str:
return jsonify({"error": (
f"Microsoft Graph email failed: {graph_error_str}\n\n"
"Make sure Mail.Send is added to your Azure app registration and admin consent has been granted:\n"
"Azure AD → App registrations → [your app] → API permissions → Add → Microsoft Graph → Mail.Send → Grant admin consent."
)}), 400
return jsonify({"error": "No SMTP host configured. To send via Microsoft 365 Graph (no SMTP needed), add Mail.Send to your Azure app registration."}), 400
try:
@@ -210,11 +216,33 @@ def smtp_test():
"(Users → Active users → [user] → Mail → Manage email apps → Authenticated SMTP), "
"or add Mail.Send to your Azure app to use Graph instead.")
elif (_personal_ms or _gmail_host) and _auth_err:
provider = "Microsoft" if _personal_ms else "Google"
url = "account.microsoft.com/security" if _personal_ms else "myaccount.google.com → Security → 2-Step Verification"
err_str = (f"Authentication failed — {provider} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url} → App passwords "
f"and use that instead of your normal password.")
if _gmail_host:
_gws_account = "@gmail.com" not in username.lower() and "@googlemail.com" not in username.lower()
if _gws_account:
err_str = ("Google Workspace SMTP authentication failed.\n\n"
"Your account uses a custom domain via Google Workspace. "
"SMTP access is controlled by your organisation's Google Workspace admin, not your personal account settings.\n\n"
"Ask your Google Workspace admin to:\n"
" • Enable 2-Step Verification for your account (required for App Passwords)\n"
" • Allow users to manage their own App Passwords (Admin console → Security → 2-Step Verification)\n"
" • Or configure SMTP relay: Admin console → Apps → Google Workspace → Gmail → Routing → SMTP relay service\n\n"
"If App Passwords are available for your account, generate one at "
"myaccount.google.com → Security → 2-Step Verification → App passwords "
"and use it instead of your normal password.")
else:
err_str = ("Gmail SMTP authentication failed.\n\n"
"Google requires an App Password for SMTP — your normal password will not work.\n\n"
"If you are already using an App Password, check:\n"
" • No spaces — the 16-character code must be entered without spaces\n"
" • The App Password has not been revoked — generate a new one at "
"myaccount.google.com → Security → 2-Step Verification → App passwords\n"
" • The correct username (your full Gmail address, e.g. you@gmail.com)\n"
" • Port 587 with STARTTLS, or port 465 with SSL")
else:
url = "account.microsoft.com/security"
err_str = (f"Authentication failed — Microsoft blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url} → App passwords "
f"and use that instead of your normal password.")
elif graph_error_str:
err_str = f"SMTP: {err_str} | Graph also unavailable (Mail.Send not granted)"
return jsonify({"error": err_str}), 200
@@ -295,9 +323,32 @@ def send_report():
err = (f"{err}\n\nTip: Enable SMTP AUTH for this mailbox in the Microsoft 365 admin centre, "
"or connect to M365 first so the scanner can send via Microsoft Graph instead.")
elif (_personal_ms_2 or _gmail_2) and _auth_err_2:
provider2 = "Microsoft" if _personal_ms_2 else "Google"
url2 = "account.microsoft.com/security" if _personal_ms_2 else "myaccount.google.com → Security → 2-Step Verification"
err = (f"Authentication failed — {provider2} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url2} → App passwords "
f"and use that instead of your normal password.")
if _gmail_2:
_uname2 = smtp_cfg.get("username", "").lower()
_gws2 = "@gmail.com" not in _uname2 and "@googlemail.com" not in _uname2
if _gws2:
err = ("Google Workspace SMTP authentication failed.\n\n"
"Your account uses a custom domain via Google Workspace. "
"SMTP access is controlled by your organisation's Google Workspace admin, not your personal account settings.\n\n"
"Ask your Google Workspace admin to:\n"
" • Enable 2-Step Verification for your account (required for App Passwords)\n"
" • Allow users to manage their own App Passwords (Admin console → Security → 2-Step Verification)\n"
" • Or configure SMTP relay: Admin console → Apps → Google Workspace → Gmail → Routing → SMTP relay service\n\n"
"If App Passwords are available for your account, generate one at "
"myaccount.google.com → Security → 2-Step Verification → App passwords "
"and use it instead of your normal password.")
else:
err = ("Gmail SMTP authentication failed.\n\n"
"Google requires an App Password for SMTP — your normal password will not work.\n\n"
"If you are already using an App Password, check:\n"
" • No spaces — the 16-character code must be entered without spaces\n"
" • The App Password has not been revoked — generate a new one at "
"myaccount.google.com → Security → 2-Step Verification → App passwords\n"
" • The correct username (your full Gmail address, e.g. you@gmail.com)\n"
" • Port 587 with STARTTLS, or port 465 with SSL")
else:
url2 = "account.microsoft.com/security"
err = (f"Authentication failed — Microsoft blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url2} → App passwords "
f"and use that instead of your normal password.")
return jsonify({"error": err}), 500
+2 -2
View File
@@ -316,13 +316,13 @@ def _run_google_scan(options: dict):
broadcast("scan_error", {"file": f"Drive/{user_email}", "error": str(e)})
elapsed = _time.monotonic() - t_start
broadcast("scan_done", {
broadcast("google_scan_done", {
"flagged_count": total_flagged,
"total_scanned": total_scanned,
"elapsed_seconds": round(elapsed, 1),
})
if _db and _db_scan_id:
try:
_db.end_scan(_db_scan_id, total_scanned, total_flagged)
_db.finish_scan(_db_scan_id, total_scanned)
except Exception:
pass
+19 -1
View File
@@ -78,9 +78,27 @@ def create_token():
if not isinstance(raw_scope, dict):
return jsonify({"error": "scope must be an object"}), 400
role = str(raw_scope.get("role", "")).strip()
# user may be a single email string (legacy) or a list of email strings
raw_user = raw_scope.get("user", "")
if isinstance(raw_user, str):
user_emails = [raw_user.strip().lower()] if raw_user.strip() else []
elif isinstance(raw_user, list):
user_emails = [str(e).strip().lower() for e in raw_user if str(e).strip()]
else:
user_emails = []
display_name = str(raw_scope.get("display_name", "")).strip()
if role and user_emails:
return jsonify({"error": "scope.role and scope.user are mutually exclusive"}), 400
if role not in ("", "student", "staff"):
return jsonify({"error": "scope.role must be '', 'student', or 'staff'"}), 400
scope = {"role": role} if role else {}
if user_emails and not all("@" in e for e in user_emails):
return jsonify({"error": "scope.user entries must be valid email addresses"}), 400
if user_emails:
scope = {"user": user_emails, "display_name": display_name or user_emails[0]}
elif role:
scope = {"role": role}
else:
scope = {}
entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
return jsonify(entry), 201