Feedback reply: admin-only WYSIWYG editor (server stays the adult)
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex greenlit for this narrow, admin-only surface). * markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text. Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong, i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles (content kept as escaped text) and discards script/style content entirely. * API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html, derives plain text → stored message + the email text/plain part. Unchanged: multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422. * Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List), execCommand with styleWithCSS=false for semantic tags, font size wraps the selection in a fixed-px span, paste intercepted as plain text. No links yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-7
@@ -32,7 +32,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share, summarize
|
||||
from .markup import render_reply_html
|
||||
from .markup import reply_html_to_text, sanitize_reply_html
|
||||
from .db import connect
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .hero import safe_to_lead
|
||||
@@ -383,7 +383,7 @@ class FeedbackReadBody(BaseModel):
|
||||
|
||||
|
||||
class FeedbackReplyBody(BaseModel):
|
||||
message: str = ""
|
||||
html: str = "" # raw editor HTML — sanitized server-side before use
|
||||
|
||||
|
||||
class SourceActiveBody(BaseModel):
|
||||
@@ -843,8 +843,10 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/admin/feedback/{fid}/reply")
|
||||
def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict:
|
||||
message = (body.message or "").strip()[:4000]
|
||||
if not message:
|
||||
# Sanitize the editor HTML to our allowlist; derive the plain-text fallback.
|
||||
reply_html = sanitize_reply_html(body.html)[:8000]
|
||||
reply_text = reply_html_to_text(reply_html)
|
||||
if not reply_text:
|
||||
raise HTTPException(status_code=422, detail="Reply message is required.")
|
||||
# 1. Validate + gather, then release the DB connection — SMTP can take
|
||||
# ~20s and shouldn't keep a connection open.
|
||||
@@ -856,11 +858,10 @@ def create_app() -> FastAPI:
|
||||
if not fb["contact_email"]:
|
||||
raise HTTPException(status_code=400, detail="No reply address for this feedback.")
|
||||
admin_id, to, original = admin["id"], fb["contact_email"], fb["message"]
|
||||
reply_html = render_reply_html(message) # tiny safe Markdown subset
|
||||
# 2. Send with no DB connection held; only record a reply that actually
|
||||
# went out, so the UI can keep the draft on failure.
|
||||
try:
|
||||
email_send.send_feedback_reply(to, message, reply_html, original)
|
||||
email_send.send_feedback_reply(to, reply_text, reply_html, original)
|
||||
except Exception as exc: # noqa: BLE001 — surface any SMTP failure to the admin
|
||||
raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}")
|
||||
# 3. Record the sent reply + mark the item read.
|
||||
@@ -868,7 +869,7 @@ def create_app() -> FastAPI:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(fid, admin_id, message, reply_html, to),
|
||||
(fid, admin_id, reply_text, reply_html, to),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,)
|
||||
|
||||
Reference in New Issue
Block a user