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:
@@ -33,6 +33,7 @@ from ..core import (
|
|||||||
utcnow,
|
utcnow,
|
||||||
utcnow_iso,
|
utcnow_iso,
|
||||||
)
|
)
|
||||||
|
from ..email_templates import EMAIL_TEMPLATE_REGISTRY, render_email_template
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1398,10 +1399,16 @@ async def email_compose():
|
|||||||
email_addresses=EMAIL_ADDRESSES,
|
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:
|
if wrap:
|
||||||
from ..worker import _email_wrap
|
html = render_email_template(
|
||||||
html = _email_wrap(html)
|
"emails/admin_compose.html",
|
||||||
|
lang="en",
|
||||||
|
body_html=body_html,
|
||||||
|
preheader="",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = body_html
|
||||||
|
|
||||||
result = await send_email(
|
result = await send_email(
|
||||||
to=to, subject=subject, html=html,
|
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 ---
|
# --- Audiences ---
|
||||||
|
|
||||||
@bp.route("/emails/audiences")
|
@bp.route("/emails/audiences")
|
||||||
|
|||||||
@@ -2,51 +2,91 @@
|
|||||||
{% set admin_page = "compose" %}
|
{% set admin_page = "compose" %}
|
||||||
{% block title %}Compose Email - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% 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 %}
|
{% block admin_content %}
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
<a href="{{ url_for('admin.emails') }}" class="text-sm text-slate">← Sent Log</a>
|
||||||
<h1 class="text-2xl mt-1">Compose Email</h1>
|
<h1 class="text-2xl mt-1">Compose Email</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card" style="padding:1.5rem;max-width:640px">
|
<div class="compose-layout">
|
||||||
<form method="post" action="{{ url_for('admin.email_compose') }}">
|
{# ── Left: form ────────────────────────────────────── #}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
<label class="text-xs font-semibold text-slate block mb-1">From</label>
|
||||||
<select name="from_addr" class="form-input">
|
<select name="from_addr" class="form-input">
|
||||||
{% for key, addr in email_addresses.items() %}
|
{% for key, addr in email_addresses.items() %}
|
||||||
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
<option value="{{ addr }}" {% if data.get('from_addr') == addr %}selected{% endif %}>{{ key | title }} — {{ addr }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">To</label>
|
<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>
|
<input type="email" name="to" value="{{ data.get('to', '') }}" class="form-input" placeholder="recipient@example.com" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Subject</label>
|
<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>
|
<input type="text" name="subject" value="{{ data.get('subject', '') }}" class="form-input" placeholder="Subject line" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Body</label>
|
<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 <br>)" required>{{ data.get('body', '') }}</textarea>
|
<textarea
|
||||||
</div>
|
name="body" rows="14" class="form-input"
|
||||||
|
placeholder="Plain text (line breaks become <br>)"
|
||||||
|
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">
|
<div class="mb-6">
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" name="wrap" value="1" checked>
|
<input
|
||||||
Wrap in branded email template
|
type="checkbox" name="wrap" value="1"
|
||||||
</label>
|
{% if data.get('wrap', True) %}checked{% endif %}
|
||||||
</div>
|
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">
|
<div class="flex gap-2">
|
||||||
<button type="submit" class="btn">Send Email</button>
|
<button type="submit" class="btn">Send Email</button>
|
||||||
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
<a href="{{ url_for('admin.emails') }}" class="btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
5
web/src/padelnomics/templates/emails/admin_compose.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "emails/_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{{ body_html | safe }}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user