Feedback reply: light Markdown formatting (bold / bullets / heading)

Per Codex: a constrained Markdown-ish composer rather than contenteditable.

* goodnews/markup.render_reply_html — escapes everything first, then introduces
  only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs,
  line breaks). No links, attributes, inline styles, or raw HTML passthrough.
* feedback_replies.message_html column (+ live migration); replies store both
  the Markdown text and the rendered HTML.
* email_send.send_feedback_reply now sends multipart text/plain + text/html
  (the sanitized render, wrapped in a trusted email template).
* Frontend: textarea + a small toolbar (Bold / • List / H) that inserts
  Markdown; the reply thread renders the server-sanitized HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 15:27:46 -04:00
parent fd4cd2ac9c
commit 0f8d5b555a
7 changed files with 160 additions and 16 deletions
+8 -5
View File
@@ -32,6 +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 .db import connect
from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead
@@ -808,7 +809,7 @@ def create_app() -> FastAPI:
ids = [it["id"] for it in items]
ph = ",".join("?" * len(ids))
reps = conn.execute(
f"SELECT id, feedback_id, message, sent_to, sent_at FROM feedback_replies "
f"SELECT id, feedback_id, message, message_html, sent_to, sent_at FROM feedback_replies "
f"WHERE feedback_id IN ({ph}) ORDER BY sent_at",
ids,
).fetchall()
@@ -855,24 +856,26 @@ 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, original)
email_send.send_feedback_reply(to, message, 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.
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO feedback_replies (feedback_id, user_id, message, sent_to) VALUES (?, ?, ?, ?)",
(fid, admin_id, message, to),
"INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) "
"VALUES (?, ?, ?, ?, ?)",
(fid, admin_id, message, reply_html, to),
)
conn.execute(
"UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,)
)
conn.commit()
reply = conn.execute(
"SELECT id, feedback_id, message, sent_to, sent_at FROM feedback_replies WHERE id = ?",
"SELECT id, feedback_id, message, message_html, sent_to, sent_at FROM feedback_replies WHERE id = ?",
(cur.lastrowid,),
).fetchone()
return {"ok": True, "reply": dict(reply)}
+6
View File
@@ -246,6 +246,7 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
feedback_id INTEGER NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
message TEXT NOT NULL,
message_html TEXT,
sent_to TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -320,3 +321,8 @@ def _migrate(conn: sqlite3.Connection) -> None:
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
if fb_cols and "read_at" not in fb_cols:
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
# feedback_replies.message_html (rendered Markdown subset) added later.
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
if rep_cols and "message_html" not in rep_cols:
conn.execute("ALTER TABLE feedback_replies ADD COLUMN message_html TEXT")
+16 -4
View File
@@ -63,19 +63,31 @@ def send_feedback(to: str, category: str, message: str, contact: str | None, who
send_email(to, subject, text)
def send_feedback_reply(to: str, reply_message: str, original_message: str) -> None:
"""Reply to a reader's feedback from the admin inbox (plain text). Quotes
def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, original_message: str) -> None:
"""Reply to a reader's feedback from the admin inbox. Sends multipart
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
their original note for context; exposes no analytics/account details."""
subject = "Re: Your Upbeat Bytes feedback"
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
text = (
f"{reply_message}\n\n"
f"{reply_text}\n\n"
"\n"
"In reply to your note to Upbeat Bytes:\n"
f"{quoted}\n\n"
"Thanks for reaching out.\n— Upbeat Bytes\n"
)
send_email(to, subject, text)
body_html = None
if reply_html:
oq = escape(original_message or "").replace("\n", "<br>")
body_html = (
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'color:#16263a;font-size:15px;line-height:1.5">'
f"{reply_html}"
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to Upbeat Bytes:</p>'
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
"<p>Thanks for reaching out.<br>— Upbeat Bytes</p></div>"
)
send_email(to, subject, text, html=body_html)
def send_magic_link(to: str, link: str) -> None:
+48
View File
@@ -0,0 +1,48 @@
"""A tiny, safe Markdown subset → HTML, for admin feedback replies.
Everything is HTML-escaped FIRST, then a small whitelist of structures is
introduced: bold (**…**), bullet lists (- / *), headings (#/##/###),
paragraphs, and line breaks. No links, no attributes, no inline styles, no raw
HTML passthrough — so the output is safe to render and to email. Authored only
by admins, but sanitized on principle regardless.
"""
from __future__ import annotations
import html
import re
_BULLET_RE = re.compile(r"^\s*[-*]\s+")
_HEADING_RE = re.compile(r"^\s*(#{1,3})\s+")
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_HEADING_TAG = {1: "h3", 2: "h4", 3: "h5"}
def _inline(s: str) -> str:
"""Escape, then apply the only inline transform we allow: **bold**."""
s = html.escape(s, quote=False)
return _BOLD_RE.sub(r"<strong>\1</strong>", s)
def render_reply_html(text: str) -> str:
"""Render the supported Markdown subset to sanitized HTML (or '' if empty)."""
text = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not text:
return ""
out: list[str] = []
for block in re.split(r"\n\s*\n", text):
lines = [ln for ln in block.split("\n")]
nonempty = [ln for ln in lines if ln.strip()]
if not nonempty:
continue
if all(_BULLET_RE.match(ln) for ln in nonempty):
items = "".join("<li>" + _inline(_BULLET_RE.sub("", ln)) + "</li>" for ln in nonempty)
out.append("<ul>" + items + "</ul>")
elif len(nonempty) == 1 and _HEADING_RE.match(nonempty[0]):
level = len(_HEADING_RE.match(nonempty[0]).group(1))
content = _HEADING_RE.sub("", nonempty[0])
tag = _HEADING_TAG[level]
out.append(f"<{tag}>" + _inline(content) + f"</{tag}>")
else:
out.append("<p>" + "<br>".join(_inline(ln) for ln in nonempty) + "</p>")
return "".join(out)