From db3dd465b27c30fcdc968e3e84911416488896f9 Mon Sep 17 00:00:00 2001
From: Scott Idem
Date: Wed, 29 Apr 2026 21:56:45 -0400
Subject: [PATCH] feat: email allowlist management in Settings + Files panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Settings page gets an editable textarea (POST /settings/email-allowlist)
so users can view and update their per-user regex allowlist without
touching the raw JSON file.
Files panel gains a "Settings" group containing email_allowlist.json as
a raw JSON editor backup — served from home/{user}/ via files.py USER_FILES.
Co-Authored-By: Claude Sonnet 4.6
---
cortex/routers/files.py | 24 +++++++++++++++++++++---
cortex/routers/settings.py | 28 ++++++++++++++++++++++++++++
cortex/static/app.js | 1 +
cortex/static/settings.html | 35 +++++++++++++++++++++++++++++++++++
4 files changed, 85 insertions(+), 3 deletions(-)
diff --git a/cortex/routers/files.py b/cortex/routers/files.py
index 9bc3d71..4c24eb5 100644
--- a/cortex/routers/files.py
+++ b/cortex/routers/files.py
@@ -6,6 +6,7 @@ import re
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from persona import persona_path, set_context, validate as validate_persona
+from config import settings as _settings
router = APIRouter()
@@ -22,6 +23,9 @@ ALLOWED = {
"HELP.md",
}
+# Files served from home/{user}/ instead of persona path
+USER_FILES = {"email_allowlist.json"}
+
def _resolve(user: str, persona: str) -> None:
"""Validate and set context from query params. Raises HTTPException on bad input."""
@@ -32,7 +36,11 @@ def _resolve(user: str, persona: str) -> None:
raise HTTPException(status_code=404, detail=str(e))
-def _path(filename: str):
+def _path(filename: str, user: str = ""):
+ if filename in USER_FILES:
+ if not user:
+ raise HTTPException(status_code=400, detail="user param required for this file")
+ return _settings.home_root() / user / filename
if filename not in ALLOWED:
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
return persona_path() / filename
@@ -55,6 +63,16 @@ async def list_files(
"size": st.st_size if st else 0,
"modified": st.st_mtime if st else None,
})
+ for name in sorted(USER_FILES):
+ p = _settings.home_root() / user / name
+ st = p.stat() if p.exists() else None
+ files.append({
+ "name": name,
+ "exists": p.exists(),
+ "size": st.st_size if st else 0,
+ "modified": st.st_mtime if st else None,
+ "scope": "user",
+ })
return {"files": files}
@@ -65,7 +83,7 @@ async def get_file(
persona: str = Query("inara"),
) -> dict:
_resolve(user, persona)
- p = _path(filename)
+ p = _path(filename, user=user)
if not p.exists():
raise HTTPException(status_code=404, detail=f"{filename} does not exist")
return {"name": filename, "content": p.read_text()}
@@ -83,7 +101,7 @@ async def save_file(
persona: str = Query("inara"),
) -> dict:
_resolve(user, persona)
- p = _path(filename)
+ p = _path(filename, user=user)
p.write_text(req.content)
return {"ok": True, "name": filename, "size": len(req.content)}
diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py
index 1b58a1d..a01424c 100644
--- a/cortex/routers/settings.py
+++ b/cortex/routers/settings.py
@@ -8,6 +8,8 @@ Routes:
POST /settings/persona/rename → rename a persona directory
"""
+import html as _html
+import json
import logging
import re
from pathlib import Path
@@ -63,6 +65,14 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
role = auth_data.get("role", "user")
html = html.replace("{{ user_role }}", role)
+ al_path = app_settings.home_root() / username / "email_allowlist.json"
+ try:
+ patterns = json.loads(al_path.read_text())
+ allowlist_text = _html.escape("\n".join(str(p) for p in patterns if str(p).strip()))
+ except Exception:
+ allowlist_text = ""
+ html = html.replace("{{ email_allowlist }}", allowlist_text)
+
persona_items = "\n".join(
f'''
{p}
@@ -228,3 +238,21 @@ async def rename_persona(
old_dir.rename(new_dir)
logger.info("persona renamed: %s/%s → %s", username, old_name, new_name)
return RedirectResponse("/settings", status_code=302)
+
+
+@router.post("/settings/email-allowlist", include_in_schema=False)
+async def save_email_allowlist(
+ request: Request,
+ patterns: str = Form(""),
+):
+ username = _get_session_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+
+ personas = list_user_personas(username)
+ back_persona = _preferred_persona(request, username)
+ lines = [ln.strip() for ln in patterns.splitlines() if ln.strip()]
+ path = app_settings.home_root() / username / "email_allowlist.json"
+ path.write_text(json.dumps(lines, indent=2))
+ logger.info("email allowlist updated for %s (%d patterns)", username, len(lines))
+ return HTMLResponse(_settings_page(username, personas, back_persona, success=f"Email allowlist saved ({len(lines)} pattern{'s' if len(lines) != 1 else ''})."))
diff --git a/cortex/static/app.js b/cortex/static/app.js
index 5fedb18..f3843e7 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -1285,6 +1285,7 @@
{ label: 'Identity', files: ['IDENTITY.md', 'SOUL.md', 'PROTOCOLS.md', 'CONTEXT_TIERS.md'] },
{ label: 'Memory', files: ['MEMORY_LONG.md', 'MEMORY_MID.md', 'MEMORY_SHORT.md'] },
{ label: 'Profile', files: ['USER.md', 'HELP.md'] },
+ { label: 'Settings', files: ['email_allowlist.json'] },
];
function fmtSize(bytes) {
diff --git a/cortex/static/settings.html b/cortex/static/settings.html
index 4e177b1..354a46c 100644
--- a/cortex/static/settings.html
+++ b/cortex/static/settings.html
@@ -240,6 +240,22 @@
color: var(--pg-muted);
border: 1px solid var(--pg-border);
}
+
+ textarea {
+ width: 100%;
+ padding: 0.65rem 0.85rem;
+ background: var(--pg-bg);
+ border: 1px solid var(--pg-border);
+ border-radius: 6px;
+ color: var(--pg-text);
+ font-size: 0.88rem;
+ font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
+ line-height: 1.55;
+ resize: vertical;
+ outline: none;
+ transition: border-color 0.15s;
+ }
+ textarea:focus { border-color: #7c3aed; }
@@ -309,6 +325,25 @@
+
+
+
Email Allowlist
+
+ One regex pattern per line. The email_send
+ tool will only send to addresses that match at least one pattern.
+ Leave blank to block all outbound email.
+
+
+
+
Browser Cache