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:
@@ -88,7 +88,7 @@ def test_feedback_admin_actions_gated(tmp_path, monkeypatch):
|
||||
anon = TestClient(app)
|
||||
assert anon.post("/api/admin/feedback/1/read", json={"read": True}).status_code == 401
|
||||
assert anon.delete("/api/admin/feedback/1").status_code == 401
|
||||
assert anon.post("/api/admin/feedback/1/reply", json={"message": "hi"}).status_code == 401
|
||||
assert anon.post("/api/admin/feedback/1/reply", json={"html": "hi"}).status_code == 401
|
||||
|
||||
|
||||
def test_feedback_actions_404_on_missing_id(tmp_path, monkeypatch):
|
||||
@@ -109,10 +109,10 @@ def test_feedback_reply_sends_stores_marks_read(tmp_path, monkeypatch):
|
||||
tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
|
||||
tc.post("/api/auth/email/verify", json={"token": link["l"].split("token=")[1]})
|
||||
fid = tc.get("/api/admin/feedback").json()[0]["id"]
|
||||
r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "Yes — a **companion app** is coming."})
|
||||
r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "Yes — a <b>companion app</b> is coming."})
|
||||
assert r.status_code == 200
|
||||
assert sent["to"] == "reader@x.com" and "companion app" in sent["msg"] and "is there an app?" in sent["orig"]
|
||||
assert "<strong>companion app</strong>" in sent["html"] # rendered markdown sent as HTML
|
||||
assert "<strong>companion app</strong>" in sent["html"] # sanitized editor HTML (b→strong)
|
||||
fb = tc.get("/api/admin/feedback").json()[0]
|
||||
assert fb["read_at"] is not None # marked read on reply
|
||||
rep = fb["replies"][0]
|
||||
@@ -125,7 +125,6 @@ def test_feedback_reply_requires_address_and_message(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("goodnews.email_send.send_feedback_reply",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
|
||||
fid = tc.get("/api/admin/feedback").json()[0]["id"]
|
||||
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": ""}).status_code == 422
|
||||
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "hi"}).status_code == 400 # no address
|
||||
assert tc.post("/api/admin/feedback/9999/reply", json={"message": "hi"}).status_code == 404
|
||||
import goodnews.api as api # already reloaded by _admin_client's app
|
||||
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": " "}).status_code == 422
|
||||
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "hi"}).status_code == 400 # no address
|
||||
assert tc.post("/api/admin/feedback/9999/reply", json={"html": "hi"}).status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user