feat(broadcast): M3 email HTML render + desktop/mobile/text preview

Step 3 of the compose wizard (Preflight) now shows a real preview +
pre-send checks. User sees exactly what the recipient gets, minus the
substituted tracking URLs.

Render (render/email-html.ts)
- renderEmailHtml(): wraps Tiptap HTML in an email-client-compatible
  shell — inline style="" throughout, single-cell table layout,
  preheader block (hidden from body, visible in inbox preview), sender
  banner, footer with unsubscribe + legal address
- HTML-escapes subject / sender name / address (Tiptap body stays
  verbatim since its schema already forbids <script> / on* handlers)
- Placeholders for {{unsubscribe}} / {{web_view}} URLs — mana-mail's
  orchestrator substitutes the HMAC-signed per-recipient URL at send
  time; preview uses #unsubscribe-preview so nothing 404s

Render (render/plain-text.ts)
- renderPlainText(): mandatory multipart/alternative counterpart that
  keeps SpamAssassin happy. Soft-wraps at 72 chars (honours the old
  78-char RFC limit with a bit of quote-prefix headroom). Same footer
  signals as HTML so the "why am I getting this" info travels with
  text-only clients too.

Preview (preview/)
- EmailPreview.svelte: iframe with srcdoc (no blob-URL revocation
  overhead) + macOS-style device chrome, width 640/375 for desktop/
  mobile. sandbox="allow-same-origin" only — no top navigation, no
  script execution.
- PreviewTabs.svelte: Desktop / Mobile / Nur-Text switcher, derives
  html + plaintext reactively so edits in step 2 reflect immediately
  when the user hops back to step 3.

Preflight checks (ComposeView step 3)
- Subject set? Audience > 0? Sender valid? Legal address filled?
- Legal-address warning is actionable — links straight to
  /broadcasts/settings. Unblocks the user instead of just scolding.

Tests (20 new, 57 total for broadcast)
- email-html: subject in title, XSS-escape, preheader hidden, body
  verbatim, empty-body fallback, sender name, unsubscribe default vs
  override, legal address line breaks, optional footer, web-view link
- plain-text: sender, body, 72-char wrapping, paragraph preservation,
  unsubscribe placeholder vs override, legal at end, optional footer

Still client-side only; server-side juice inlining lands in M4 when we
add richer CSS (currently everything inline already, so juice just
passes through).

Plan: docs/plans/broadcast-module.md §M3.
Next: M4 bulk-send via mana-mail (synchronous loop, HMAC tokens).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 20:48:14 +02:00
parent 38d35247cd
commit 3ac32d9f3e
8 changed files with 828 additions and 15 deletions

View file

@ -26,6 +26,9 @@ export {
export { broadcastCampaignsStore } from './stores/campaigns.svelte';
export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
export { renderEmailHtml } from './render/email-html';
export { renderPlainText } from './render/plain-text';
export {
STATUS_LABELS,
STATUS_COLORS,

View file

@ -0,0 +1,98 @@
<!--
EmailPreview — shared iframe-based preview for desktop and mobile.
Uses srcdoc instead of a blob URL so we don't have to manage URL
revocation, and srcdoc makes the iframe treat the content as a
same-origin-ish document (but sandboxed — no top navigation).
The `viewport` prop controls iframe width so one component powers
both Desktop (640px) and Mobile (375px) views.
-->
<script lang="ts">
interface Props {
html: string;
/** Display width in CSS pixels. Height adjusts to content. */
viewport: 'desktop' | 'mobile';
}
let { html, viewport }: Props = $props();
// iPhone 14 is 390 CSS px, but 375 is the well-known legacy default
// that most "does this look ok on iPhone?" previews use. Wider than
// 640 on desktop gets cramped in the composer split-view.
const width = $derived(viewport === 'mobile' ? 375 : 640);
</script>
<div class="preview-shell" class:mobile={viewport === 'mobile'}>
<div class="device-chrome">
<span class="dot r"></span>
<span class="dot y"></span>
<span class="dot g"></span>
<span class="device-label">{viewport === 'mobile' ? 'Mobile' : 'Desktop'}</span>
</div>
<iframe
title="Vorschau {viewport}"
srcdoc={html}
sandbox="allow-same-origin"
loading="lazy"
style="width:{width}px;"
></iframe>
</div>
<style>
.preview-shell {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 1rem;
background: var(--color-surface-muted, #f1f5f9);
border-radius: 0.75rem;
}
.device-chrome {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: #e2e8f0;
border-radius: 0.5rem 0.5rem 0 0;
width: var(--chrome-w, 640px);
max-width: 100%;
font-size: 0.75rem;
color: #64748b;
}
.preview-shell.mobile .device-chrome {
--chrome-w: 375px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot.r {
background: #ef4444;
}
.dot.y {
background: #f59e0b;
}
.dot.g {
background: #22c55e;
}
.device-label {
margin-left: auto;
}
iframe {
max-width: 100%;
height: 600px;
border: 0;
background: white;
border-radius: 0 0 0.5rem 0.5rem;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
</style>

View file

@ -0,0 +1,160 @@
<!--
PreviewTabs — Desktop / Mobile / Text switcher for the Preflight step.
Lazily renders HTML + plaintext from the current campaign + settings.
Re-derives on every change so the preview stays live as the user hops
back to step 2 and edits.
-->
<script lang="ts">
import EmailPreview from './EmailPreview.svelte';
import { renderEmailHtml } from '../render/email-html';
import { renderPlainText } from '../render/plain-text';
import type { Campaign, BroadcastSettings, CampaignContent } from '../types';
interface Props {
campaign: Pick<Campaign, 'subject' | 'preheader' | 'fromName' | 'fromEmail'>;
content: CampaignContent;
settings: BroadcastSettings;
}
let { campaign, content, settings }: Props = $props();
type Tab = 'desktop' | 'mobile' | 'text';
let tab = $state<Tab>('desktop');
const html = $derived(
renderEmailHtml({
tiptapHtml: content.html ?? '',
campaign,
settings,
})
);
const plainText = $derived(
renderPlainText({
tiptapText: content.plainText ?? '',
campaign,
settings,
})
);
</script>
<div class="preview-tabs">
<div class="tab-bar" role="tablist">
<button
type="button"
class="tab"
class:active={tab === 'desktop'}
role="tab"
aria-selected={tab === 'desktop'}
onclick={() => (tab = 'desktop')}
>
🖥 Desktop
</button>
<button
type="button"
class="tab"
class:active={tab === 'mobile'}
role="tab"
aria-selected={tab === 'mobile'}
onclick={() => (tab = 'mobile')}
>
📱 Mobile
</button>
<button
type="button"
class="tab"
class:active={tab === 'text'}
role="tab"
aria-selected={tab === 'text'}
onclick={() => (tab = 'text')}
>
📝 Nur-Text
</button>
</div>
{#if tab === 'desktop'}
<EmailPreview {html} viewport="desktop" />
{:else if tab === 'mobile'}
<EmailPreview {html} viewport="mobile" />
{:else}
<div class="plain-text-preview">
<pre>{plainText}</pre>
<p class="hint">
Der Text wird als <code>text/plain</code> zusätzlich zur HTML-Version verschickt — wichtig für
Spam-Filter und Clients, die kein HTML anzeigen.
</p>
</div>
{/if}
</div>
<style>
.preview-tabs {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tab-bar {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-surface-muted, #f1f5f9);
border-radius: 0.4rem;
width: fit-content;
}
.tab {
padding: 0.4rem 0.9rem;
background: transparent;
border: 0;
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.tab:hover {
background: rgba(255, 255, 255, 0.5);
}
.tab.active {
background: white;
color: var(--color-text, #0f172a);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.plain-text-preview {
padding: 1rem;
background: var(--color-surface-muted, #f1f5f9);
border-radius: 0.5rem;
}
.plain-text-preview pre {
background: white;
padding: 1.25rem;
border-radius: 0.4rem;
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
word-wrap: break-word;
border: 1px solid var(--color-border, #e2e8f0);
max-height: 500px;
overflow-y: auto;
margin: 0;
}
.plain-text-preview .hint {
margin: 0.75rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.plain-text-preview code {
background: #e2e8f0;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
font-size: 0.8rem;
}
</style>

View file

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { renderEmailHtml } from './email-html';
const campaign = {
subject: 'Hallo Welt',
preheader: 'Ein kurzer Vorschautext',
fromName: 'Till',
fromEmail: 'till@example.ch',
};
const settings = {
defaultFooter: 'Weitere Infos auf mana.how',
legalAddress: 'Till AG\nBahnhofstr. 1\n8000 Zürich',
};
describe('renderEmailHtml', () => {
it('produces a full HTML document', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>Hallo</p>',
campaign,
settings,
});
expect(html.toLowerCase()).toContain('<!doctype html>');
expect(html).toContain('<html');
expect(html).toContain('</html>');
});
it('includes the subject as the document title', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
});
expect(html).toContain('<title>Hallo Welt</title>');
});
it('HTML-escapes the subject to prevent injection', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign: { ...campaign, subject: 'Alert <script>x</script>' },
settings,
});
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('embeds the preheader in a hidden block', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
});
expect(html).toContain('Ein kurzer Vorschautext');
// The preheader block must be hidden from visible rendering.
expect(html).toContain('display:none');
});
it('injects the Tiptap HTML body verbatim (trusted source)', () => {
const tiptapHtml = '<h2>Willkommen</h2><p>Bold <strong>text</strong></p>';
const html = renderEmailHtml({ tiptapHtml, campaign, settings });
expect(html).toContain('<h2>Willkommen</h2>');
expect(html).toContain('<strong>text</strong>');
});
it('falls back to a blank paragraph for empty body', () => {
const html = renderEmailHtml({ tiptapHtml: '', campaign, settings });
// Body should still render without breaking the document shell.
expect(html).toContain('<p>&nbsp;</p>');
});
it('includes the sender name above the body', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
});
expect(html).toContain('>Till<');
});
it('includes unsubscribe link (preview placeholder by default)', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
});
expect(html).toContain('Abbestellen');
expect(html).toContain('#unsubscribe-preview');
});
it('uses the provided unsubscribeUrl when set (send-time)', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
unsubscribeUrl: 'https://mail.mana.how/v1/mail/track/unsubscribe/abc',
});
expect(html).toContain('https://mail.mana.how/v1/mail/track/unsubscribe/abc');
});
it('renders legal address in the footer with line breaks', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
});
expect(html).toContain('Till AG');
expect(html).toContain('8000 Zürich');
// Newlines should become <br> in the footer.
expect(html).toMatch(/Till AG<br>Bahnhofstr/);
});
it('omits the footer block when defaultFooter is empty', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings: { defaultFooter: null, legalAddress: 'X AG' },
});
// The optional "Weitere Infos" block shouldn't appear, but the legal
// address still does.
expect(html).not.toContain('Weitere Infos');
expect(html).toContain('X AG');
});
it('includes the web-view link', () => {
const html = renderEmailHtml({
tiptapHtml: '<p>body</p>',
campaign,
settings,
webViewUrl: 'https://mail.mana.how/view/abc123',
});
expect(html).toContain('https://mail.mana.how/view/abc123');
expect(html).toContain('Im Browser ansehen');
});
});

View file

@ -0,0 +1,130 @@
/**
* Render a complete email HTML document around Tiptap-generated content.
*
* The job is split across two environments:
* - THIS file (client-side): wraps the user's content in an email-
* compatible shell with preheader + footer + unsubscribe. Uses
* inline style="" attributes throughout because email clients have
* patchy support for <style> blocks, CSS variables, Flexbox, or Grid.
* Good enough for Gmail / Apple Mail / iOS Mail / Outlook.com.
* - M4's server-side inlining pass (juice in mana-mail): expands the
* remaining shorthand / hover / @media rules into inline styles per-
* recipient. Only relevant if we ever add richer CSS.
*
* For now, everything already-inline client-side means the preview the
* user sees = what the recipient sees (minus the substituted URLs).
*
* Placeholders substituted at send time (mana-mail):
* {{unsubscribe_url}} HMAC-signed one-click abmelden link
* {{web_view_url}} public URL to the rendered campaign
*
* In preview, those stay as literal `#unsubscribe-preview` etc. so the
* user can still click them without hitting a broken server route.
*/
import type { Campaign, BroadcastSettings } from '../types';
export interface RenderEmailInput {
/** HTML produced by Tiptap's getHTML(). Assumed safe Tiptap's schema
* doesn't emit <script>, on* handlers, or unknown attributes. */
tiptapHtml: string;
campaign: Pick<Campaign, 'subject' | 'preheader' | 'fromName' | 'fromEmail'>;
settings: Pick<BroadcastSettings, 'defaultFooter' | 'legalAddress'>;
/** Pass `'#preview'` for the client preview; mana-mail overrides with
* the signed per-recipient URL at send time. */
unsubscribeUrl?: string;
webViewUrl?: string;
}
/**
* Escape a string for safe HTML attribute / text node insertion. Covers
* the five chars that matter for XSS in an HTML context.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Invisible preheader: Gmail / Apple Mail show whatever follows the
* subject in the inbox list. This is the one place where an invisible
* span is standard best practice we hide the text with zero font-size
* and zero opacity, then pad with whitespace so nothing else bleeds
* through into the preview. 120 chars of padding is what Mailchimp
* uses; good enough.
*/
function preheaderBlock(preheader: string): string {
const text = escapeHtml(preheader);
// Eight Unicode zero-width non-joiners interleaved with nbsps — pushes
// the preview cutoff past the preheader without visible characters.
const padding = '&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;'.repeat(
15
);
return `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#ffffff;opacity:0;">${text}${padding}</div>`;
}
/**
* Render the complete email HTML. Pure function callers are
* responsible for providing the final URLs.
*/
export function renderEmailHtml(input: RenderEmailInput): string {
const subject = escapeHtml(input.campaign.subject || '');
const fromName = escapeHtml(input.campaign.fromName || '');
const preheader = input.campaign.preheader ? preheaderBlock(input.campaign.preheader) : '';
const unsubscribeUrl = input.unsubscribeUrl ?? '#unsubscribe-preview';
const webViewUrl = input.webViewUrl ?? '#web-view-preview';
const footerHtml = input.settings.defaultFooter
? escapeHtml(input.settings.defaultFooter).replace(/\n/g, '<br>')
: '';
const legalAddress = escapeHtml(input.settings.legalAddress ?? '').replace(/\n/g, '<br>');
// Body is NOT escaped — it's Tiptap's output which we trust.
const body = input.tiptapHtml || '<p>&nbsp;</p>';
// Single-cell table layout is the most portable container across
// Outlook + Gmail + Apple Mail. Wider than 600px looks bad on mobile;
// narrower than 560 loses density on desktop.
return `<!doctype html>
<html lang="de"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>${subject}</title>
</head><body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;line-height:1.55;">
${preheader}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f5f9;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,0.06);">
<tr><td style="padding:12px 24px;font-size:12px;color:#64748b;text-align:right;">
<a href="${escapeHtml(webViewUrl)}" style="color:#64748b;text-decoration:underline;">Im Browser ansehen</a>
</td></tr>
<tr><td style="padding:0 24px 8px;">
<div style="font-size:18px;font-weight:600;color:#0f172a;">${fromName}</div>
</td></tr>
<tr><td style="padding:8px 24px 24px;font-size:16px;color:#0f172a;">
${body}
</td></tr>
<tr><td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#64748b;">
${footerHtml ? `<div style="margin-bottom:8px;">${footerHtml}</div>` : ''}
<div style="margin-bottom:8px;">
Du erhältst diese E-Mail, weil du dich dafür angemeldet hast.
<a href="${escapeHtml(unsubscribeUrl)}" style="color:#6366f1;text-decoration:underline;">Abbestellen</a>
</div>
<div style="color:#94a3b8;">
${legalAddress}
</div>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}

View file

@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { renderPlainText } from './plain-text';
const campaign = { fromName: 'Till' };
const settings = {
defaultFooter: 'Weitere Infos auf mana.how',
legalAddress: 'Till AG, Bahnhofstr. 1, 8000 Zürich',
};
describe('renderPlainText', () => {
it('includes the sender name', () => {
const txt = renderPlainText({
tiptapText: 'Hallo',
campaign,
settings,
});
expect(txt).toContain('Till');
});
it('includes the body content', () => {
const txt = renderPlainText({
tiptapText: 'Der Körper der Nachricht.',
campaign,
settings,
});
expect(txt).toContain('Der Körper der Nachricht.');
});
it('soft-wraps long paragraphs at 72 chars', () => {
const long = 'wort '.repeat(30).trim(); // ~150 chars of 4-char words
const txt = renderPlainText({
tiptapText: long,
campaign,
settings,
});
// Every non-empty line in the body must be ≤72 chars.
for (const line of txt.split('\n')) {
expect(line.length).toBeLessThanOrEqual(72);
}
});
it('preserves multiple paragraphs separated by newlines', () => {
const txt = renderPlainText({
tiptapText: 'Absatz eins.\nAbsatz zwei.',
campaign,
settings,
});
expect(txt).toContain('Absatz eins.');
expect(txt).toContain('Absatz zwei.');
});
it('adds unsubscribe line with placeholder text by default', () => {
const txt = renderPlainText({
tiptapText: 'body',
campaign,
settings,
});
expect(txt).toContain('Abbestellen:');
expect(txt).toContain('[Abmelde-Link wird beim Versand eingefügt]');
});
it('uses the provided unsubscribeUrl when set', () => {
const txt = renderPlainText({
tiptapText: 'body',
campaign,
settings,
unsubscribeUrl: 'https://mail.mana.how/u/abc',
});
expect(txt).toContain('https://mail.mana.how/u/abc');
expect(txt).not.toContain('[Abmelde-Link');
});
it('ends with the legal address', () => {
const txt = renderPlainText({
tiptapText: 'body',
campaign,
settings,
});
expect(txt).toContain('Till AG');
expect(txt).toContain('8000 Zürich');
});
it('omits the optional footer when not set', () => {
const txt = renderPlainText({
tiptapText: 'body',
campaign,
settings: { defaultFooter: null, legalAddress: 'X AG' },
});
expect(txt).not.toContain('Weitere Infos');
});
});

View file

@ -0,0 +1,81 @@
/**
* Plain-text fallback for the email.
*
* RFC 2822 allows multipart/alternative with text + html bodies; anti-
* spam scoring (SpamAssassin etc.) penalises html-only campaigns because
* legit mail usually has both. Tiptap already produces plain text via
* getText(); we format it minimally and append the same footer signals
* (sender, unsubscribe, legal address) as the HTML version.
*
* Wrapping: email clients still honour the ancient 78-char line limit
* from RFC 2822. We soft-wrap at 72 so there's headroom for quote
* prefixes when someone forwards.
*/
import type { Campaign, BroadcastSettings } from '../types';
export interface RenderPlainTextInput {
tiptapText: string;
campaign: Pick<Campaign, 'fromName'>;
settings: Pick<BroadcastSettings, 'defaultFooter' | 'legalAddress'>;
unsubscribeUrl?: string;
webViewUrl?: string;
}
/**
* Greedy word-wrap at `width` characters. Doesn't break inside words
* a very long URL stays on one line (gets line-wrapped by the mail
* client if it has to). Preserves existing newlines.
*/
function softWrap(text: string, width: number): string {
const out: string[] = [];
for (const paragraph of text.split('\n')) {
if (paragraph.length <= width) {
out.push(paragraph);
continue;
}
let line = '';
for (const word of paragraph.split(/\s+/)) {
if (!word) continue;
const probe = line ? `${line} ${word}` : word;
if (probe.length <= width) {
line = probe;
} else {
if (line) out.push(line);
line = word;
}
}
if (line) out.push(line);
}
return out.join('\n');
}
export function renderPlainText(input: RenderPlainTextInput): string {
const body = softWrap((input.tiptapText ?? '').trim(), 72);
const fromName = input.campaign.fromName || '';
const footer = input.settings.defaultFooter?.trim();
const legal = input.settings.legalAddress?.trim();
const unsubscribeUrl = input.unsubscribeUrl ?? '[Abmelde-Link wird beim Versand eingefügt]';
const webViewUrl = input.webViewUrl ?? '[Browser-Link wird beim Versand eingefügt]';
const parts: string[] = [];
if (webViewUrl && !webViewUrl.startsWith('[')) {
parts.push(`Im Browser ansehen: ${webViewUrl}`, '');
}
if (fromName) parts.push(fromName, '');
if (body) parts.push(body, '');
parts.push('---');
if (footer) {
parts.push(softWrap(footer, 72), '');
}
parts.push(
'Du erhältst diese E-Mail, weil du dich dafür angemeldet hast.',
`Abbestellen: ${unsubscribeUrl}`,
''
);
if (legal) parts.push(softWrap(legal, 72));
return parts.join('\n');
}

View file

@ -13,8 +13,10 @@
import { goto } from '$app/navigation';
import AudienceBuilder from '../audience/AudienceBuilder.svelte';
import Editor from '../editor/Editor.svelte';
import PreviewTabs from '../preview/PreviewTabs.svelte';
import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
import type { Campaign, CampaignContent, AudienceDefinition } from '../types';
import { broadcastSettingsStore } from '../stores/settings.svelte';
import type { Campaign, CampaignContent, AudienceDefinition, BroadcastSettings } from '../types';
interface Props {
existing?: Campaign;
@ -45,6 +47,16 @@
let saving = $state(false);
let error = $state<string | null>(null);
let savedAt = $state<string | null>(null);
let settings = $state<BroadcastSettings | null>(null);
// Load settings once for the Preflight preview. Defaults to the
// current row so the preview reflects what the user just typed in
// Settings without an extra reload.
$effect(() => {
broadcastSettingsStore.get().then((s) => {
settings = s;
});
});
// ─── Save ───────────────────────────────────────────────────
async function save() {
@ -187,15 +199,50 @@
</section>
{:else if step === 3}
<section class="step-panel">
<div class="placeholder">
<h3>Preflight</h3>
<p>Spam-Score, DNS-Checks und Empfänger-Übersicht folgen in M3/M8.</p>
<p class="hint">
Empfänger: <strong>{audience.estimatedCount}</strong><br />
Betreff: <strong>{subject || '—'}</strong><br />
Absender: <strong>{fromName} &lt;{fromEmail}&gt;</strong>
</p>
<!-- Preflight checks — caught early so the user can fix before M4 send. -->
<div class="preflight-checks">
<h3>Vor dem Versand</h3>
<ul class="check-list">
<li class:ok={subject.trim().length > 0} class:warn={!subject.trim()}>
<span class="icon">{subject.trim() ? '✓' : '!'}</span>
Betreff {subject.trim() ? 'gesetzt' : 'fehlt'}
{#if subject.trim().length > 0}
<small>{subject}</small>
{/if}
</li>
<li class:ok={audience.estimatedCount > 0} class:warn={audience.estimatedCount === 0}>
<span class="icon">{audience.estimatedCount > 0 ? '✓' : '!'}</span>
{audience.estimatedCount} Empfänger
{#if audience.estimatedCount === 0}
<small>— kein Empfänger matched die Filter</small>
{/if}
</li>
<li class:ok={!!fromEmail && fromEmail.includes('@')} class:warn={!fromEmail}>
<span class="icon">{fromEmail.includes('@') ? '✓' : '!'}</span>
Absender
<small>{fromName} &lt;{fromEmail || '—'}&gt;</small>
</li>
<li
class:ok={!!settings?.legalAddress?.trim()}
class:warn={!settings?.legalAddress?.trim()}
>
<span class="icon">{settings?.legalAddress?.trim() ? '✓' : '!'}</span>
Impressum
{#if !settings?.legalAddress?.trim()}
<small
>— Pflicht laut DSGVO.
<a href="/broadcasts/settings">In Einstellungen ergänzen →</a>
</small>
{/if}
</li>
</ul>
</div>
{#if settings}
<PreviewTabs campaign={{ subject, preheader, fromName, fromEmail }} {content} {settings} />
{:else}
<p class="loading">Lade Einstellungen …</p>
{/if}
</section>
{:else if step === 4}
<section class="step-panel">
@ -374,12 +421,6 @@
color: var(--color-text, #0f172a);
}
.placeholder .hint {
margin-top: 1rem;
text-align: left;
display: inline-block;
}
.btn-primary {
background: #6366f1;
color: white;
@ -403,4 +444,79 @@
cursor: pointer;
font-size: 0.9rem;
}
.preflight-checks {
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
}
.preflight-checks h3 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
font-weight: 600;
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.check-list li {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.9rem;
}
.check-list .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.check-list li.ok .icon {
background: #dcfce7;
color: #15803d;
}
.check-list li.warn .icon {
background: #fef3c7;
color: #92400e;
}
.check-list li.warn {
color: #92400e;
}
.check-list small {
color: var(--color-text-muted, #64748b);
font-size: 0.85rem;
}
.check-list li.warn small {
color: #92400e;
}
.check-list a {
color: #6366f1;
text-decoration: underline;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
</style>