0f8d5b555a
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>
115 lines
4.4 KiB
Python
115 lines
4.4 KiB
Python
"""Minimal transactional email over SMTP (magic-link sign-in).
|
|
|
|
Config comes from the environment (GOODNEWS_SMTP_*), supplied to the API
|
|
container via its env_file. STARTTLS submission; plain-text with a simple HTML
|
|
alternative. No third-party email API — just the configured relay.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
from html import escape
|
|
|
|
|
|
def smtp_configured() -> bool:
|
|
return bool(os.environ.get("GOODNEWS_SMTP_HOST"))
|
|
|
|
|
|
def _cfg() -> dict:
|
|
return {
|
|
"host": os.environ.get("GOODNEWS_SMTP_HOST", ""),
|
|
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
|
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
|
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
|
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"),
|
|
}
|
|
|
|
|
|
def send_email(to: str, subject: str, text: str, html: str | None = None) -> None:
|
|
"""Send one message. Raises on failure (caller decides how loud to be)."""
|
|
cfg = _cfg()
|
|
if not cfg["host"]:
|
|
raise RuntimeError("SMTP not configured (set GOODNEWS_SMTP_HOST)")
|
|
msg = EmailMessage()
|
|
msg["From"] = cfg["sender"]
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.set_content(text)
|
|
if html:
|
|
msg.add_alternative(html, subtype="html")
|
|
context = ssl.create_default_context()
|
|
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server:
|
|
server.ehlo()
|
|
server.starttls(context=context)
|
|
server.ehlo()
|
|
if cfg["user"]:
|
|
server.login(cfg["user"], cfg["password"])
|
|
server.send_message(msg)
|
|
|
|
|
|
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
|
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
|
subject = f"Upbeat Bytes feedback · {category}"
|
|
reply = contact or "(none given)"
|
|
text = (
|
|
f"New feedback ({category})\n"
|
|
f"From: {who}\n"
|
|
f"Reply to: {reply}\n\n"
|
|
f"{message}\n"
|
|
)
|
|
send_email(to, subject, text)
|
|
|
|
|
|
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_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"
|
|
)
|
|
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:
|
|
"""Send a calm, single-purpose sign-in email."""
|
|
subject = "Your Upbeat Bytes sign-in link"
|
|
text = (
|
|
"Welcome back to Upbeat Bytes.\n\n"
|
|
f"Tap to sign in:\n{link}\n\n"
|
|
"This link works once and expires in 15 minutes.\n"
|
|
"If you didn't request it, you can safely ignore this email."
|
|
)
|
|
safe = escape(link, quote=True)
|
|
html = (
|
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
|
'color:#16263a;line-height:1.6">'
|
|
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
|
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
|
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
|
"Sign in</a></p>"
|
|
'<p style="color:#5d6b78;font-size:14px">This link works once and expires in 15 minutes. '
|
|
"If you didn't request it, you can safely ignore this email.</p>"
|
|
"</div>"
|
|
)
|
|
send_email(to, subject, text, html)
|