feat(emails): subtask 5 — compose preview (admin_compose template + HTMX endpoint)

- Add emails/admin_compose.html: branded wrapper for ad-hoc compose body
- Update email_compose.html: two-column layout with HTMX live preview pane
  (hx-post, hx-trigger=input delay:500ms, hx-target=#preview-pane)
- Add partials/email_preview_frame.html: sandboxed iframe partial
- Add POST /admin/emails/compose/preview route (no CSRF — read-only render)
- Update email_compose POST handler to use render_email_template() instead
  of importing _email_wrap from worker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 12:12:09 +01:00
parent c31d4a71a0
commit 536d5c8f40
4 changed files with 126 additions and 36 deletions

View File

@@ -33,6 +33,7 @@ from ..core import (
utcnow,
utcnow_iso,
)
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
logger = logging.getLogger(__name__)
@@ -1398,10 +1399,16 @@ async def email_compose():
email_addresses=EMAIL_ADDRESSES,
)
html = f"<p>{body.replace(chr(10), '<br>')}</p>"
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>"
if wrap:
from ..worker import _email_wrap
html = _email_wrap(html)
html = render_email_template(
"emails/admin_compose.html",
lang="en",
body_html=body_html,
preheader="",
)
else:
html = body_html
result = await send_email(
to=to, subject=subject, html=html,
@@ -1424,6 +1431,36 @@ async def email_compose():
)
@bp.route("/emails/compose/preview", methods=["POST"])
@role_required("admin")
async def compose_preview():
"""HTMX endpoint: render live preview for compose textarea (no CSRF — read-only)."""
form = await request.form
body = form.get("body", "").strip()
wrap = form.get("wrap", "") == "1"
body_html = f"<p>{body.replace(chr(10), '<br>')}</p>" if body else ""
if wrap and body_html:
try:
rendered_html = render_email_template(
"emails/admin_compose.html",
lang="en",
body_html=body_html,
preheader="",
)
except Exception:
logger.exception("compose_preview: template render failed")
rendered_html = body_html
else:
rendered_html = body_html
return await render_template(
"admin/partials/email_preview_frame.html",
rendered_html=rendered_html,
)
# --- Audiences ---
@bp.route("/emails/audiences")

View File

@@ -2,51 +2,91 @@
{% set admin_page = "compose" %}
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.compose-layout { display: grid; grid-template-columns: 480px 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 1100px) { .compose-layout { grid-template-columns: 1fr; } }
.preview-panel { position: sticky; top: 1rem; }
.preview-label { font-size: 0.75rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
#preview-pane { min-height: 400px; background: #F1F5F9; border-radius: 8px; }
#preview-pane.loading { opacity: 0.5; }
</style>
{% endblock %}
{% block admin_content %}
<header class="mb-6">
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">&larr; Sent Log</a>
<h1 class="text-2xl mt-1">Compose Email</h1>
</header>
<div class="card" style="padding:1.5rem;max-width:640px">
<form method="post" action="{{ url_for('admin.email_compose') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="compose-layout">
{# ── Left: form ────────────────────────────────────── #}
<div>
<div class="card" style="padding:1.5rem;">
<form id="compose-form" method="post" action="{{ url_for('admin.email_compose') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">From</label>
<select name="from_addr" class="form-input">
{% for key, addr in email_addresses.items() %}
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">From</label>
<select name="from_addr" class="form-input">
{% for key, addr in email_addresses.items() %}
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">To</label>
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">To</label>
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
<textarea name="body" rows="12" class="form-input" placeholder="Plain text (line breaks become &lt;br&gt;)" required>{{ data.get('body', '') }}</textarea>
</div>
<div class="mb-4">
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
<textarea
name="body" rows="14" class="form-input"
placeholder="Plain text (line breaks become &lt;br&gt;)"
required
hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="input delay:500ms, change"
hx-target="#preview-pane"
hx-include="#compose-form [name='wrap']"
hx-indicator="#preview-pane"
>{{ data.get('body', '') }}</textarea>
</div>
<div class="mb-6">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="wrap" value="1" checked>
Wrap in branded email template
</label>
</div>
<div class="mb-6">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox" name="wrap" value="1"
{% if data.get('wrap', True) %}checked{% endif %}
hx-post="{{ url_for('admin.compose_preview') }}"
hx-trigger="change"
hx-target="#preview-pane"
hx-include="#compose-form textarea[name='body']"
>
Wrap in branded email template
</label>
</div>
<div class="flex gap-2">
<button type="submit" class="btn">Send Email</button>
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
<div class="flex gap-2">
<button type="submit" class="btn">Send Email</button>
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
</div>
</form>
</div>
</form>
</div>
{# ── Right: live preview panel ─────────────────────── #}
<div class="preview-panel">
<div class="preview-label">Live preview</div>
<div id="preview-pane">
<p style="padding:2rem;font-size:0.875rem;color:#94A3B8;text-align:center;">Start typing to see a preview…</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{# HTMX partial: sandboxed iframe showing a rendered email preview.
Rendered by POST /admin/emails/compose/preview. #}
<iframe
srcdoc="{{ rendered_html | e }}"
style="width:100%;height:600px;border:none;border-radius:8px;background:#F1F5F9;"
sandbox="allow-same-origin"
title="Email preview"
></iframe>

View File

@@ -0,0 +1,5 @@
{% extends "emails/_base.html" %}
{% block body %}
{{ body_html | safe }}
{% endblock %}