mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
38d35247cd
commit
3ac32d9f3e
8 changed files with 828 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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('<script>');
|
||||
});
|
||||
|
||||
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> </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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = '͏‌ ͏‌ ͏‌ ͏‌ '.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> </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>`;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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} <{fromEmail}></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} <{fromEmail || '—'}></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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue