From 0f8d5b555a6ef769363159ec1b8cf4074f6cba05 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 8 Jun 2026 15:27:46 -0400 Subject: [PATCH] Feedback reply: light Markdown formatting (bold / bullets / heading) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/routes/admin/+page.svelte | 57 ++++++++++++++++++++++++-- goodnews/api.py | 13 +++--- goodnews/db.py | 6 +++ goodnews/email_send.py | 20 +++++++-- goodnews/markup.py | 48 ++++++++++++++++++++++ tests/test_feedback.py | 9 ++-- tests/test_markup.py | 23 +++++++++++ 7 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 goodnews/markup.py create mode 100644 tests/test_markup.py diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 5b8453c..7bd1e14 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -136,13 +136,41 @@ } } - // In-site reply (plain text, sent via SMTP; only stored on successful send). + // In-site reply (Markdown-ish: textarea + toolbar; server renders sanitized HTML). let replyingId = $state(null); let replyText = $state(''); let replyBusy = $state(false); let replyErr = $state(''); + let replyTextarea = $state(null); function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; } function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; } + + // Wrap the current selection (e.g. **bold**), restoring focus + selection. + function mdWrap(token) { + const el = replyTextarea; + if (!el) return; + const s = el.selectionStart, e = el.selectionEnd; + const sel = replyText.slice(s, e) || 'text'; + replyText = replyText.slice(0, s) + token + sel + token + replyText.slice(e); + queueMicrotask(() => { + el.focus(); + el.selectionStart = s + token.length; + el.selectionEnd = s + token.length + sel.length; + }); + } + // Prefix each selected line (e.g. "- " or "## "). + function mdPrefix(prefix) { + const el = replyTextarea; + if (!el) return; + const s = el.selectionStart, e = el.selectionEnd; + const start = replyText.lastIndexOf('\n', s - 1) + 1; + let end = replyText.indexOf('\n', e); + if (end === -1) end = replyText.length; + const block = replyText.slice(start, end) || 'item'; + const prefixed = block.split('\n').map((ln) => prefix + ln).join('\n'); + replyText = replyText.slice(0, start) + prefixed + replyText.slice(end); + queueMicrotask(() => el.focus()); + } async function sendReply(f) { const msg = replyText.trim(); if (!msg || replyBusy) return; @@ -434,7 +462,12 @@ {#each f.replies as r (r.id)}
↪ Replied · {fwhen(r.sent_at)} -

{r.message}

+ {#if r.message_html} + +
{@html r.message_html}
+ {:else} +

{r.message}

+ {/if}
{/each} @@ -442,7 +475,12 @@ {#if replyingId === f.id}
- +
+ + + +
+ {#if replyErr}

{replyErr}

{/if}