feat: email allowlist management in Settings + Files panel
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}
|
||||
|
||||
|
||||
@@ -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'''<li>
|
||||
<a href="/{username}/{p}" class="persona-link">{p}</a>
|
||||
@@ -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 ''})."))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -309,6 +325,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Allowlist -->
|
||||
<div class="section">
|
||||
<h2>Email Allowlist</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
One regex pattern per line. The <code style="font-size:0.82rem; background:var(--pg-bg); padding:0.1rem 0.35rem; border-radius:4px;">email_send</code>
|
||||
tool will only send to addresses that match at least one pattern.
|
||||
Leave blank to block all outbound email.
|
||||
</p>
|
||||
<form method="POST" action="/settings/email-allowlist">
|
||||
<div class="field">
|
||||
<label for="email_allowlist_ta">Allowed patterns</label>
|
||||
<textarea id="email_allowlist_ta" name="patterns" rows="6"
|
||||
placeholder=".*@example\.com alice@example\.com"
|
||||
spellcheck="false">{{ email_allowlist }}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Browser cache -->
|
||||
<div class="section">
|
||||
<h2>Browser Cache</h2>
|
||||
|
||||
Reference in New Issue
Block a user