mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 20:06:42 +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 { broadcastCampaignsStore } from './stores/campaigns.svelte';
|
||||||
export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
|
export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
|
||||||
|
|
||||||
|
export { renderEmailHtml } from './render/email-html';
|
||||||
|
export { renderPlainText } from './render/plain-text';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
STATUS_COLORS,
|
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 { goto } from '$app/navigation';
|
||||||
import AudienceBuilder from '../audience/AudienceBuilder.svelte';
|
import AudienceBuilder from '../audience/AudienceBuilder.svelte';
|
||||||
import Editor from '../editor/Editor.svelte';
|
import Editor from '../editor/Editor.svelte';
|
||||||
|
import PreviewTabs from '../preview/PreviewTabs.svelte';
|
||||||
import { broadcastCampaignsStore } from '../stores/campaigns.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 {
|
interface Props {
|
||||||
existing?: Campaign;
|
existing?: Campaign;
|
||||||
|
|
@ -45,6 +47,16 @@
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let savedAt = $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 ───────────────────────────────────────────────────
|
// ─── Save ───────────────────────────────────────────────────
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|
@ -187,15 +199,50 @@
|
||||||
</section>
|
</section>
|
||||||
{:else if step === 3}
|
{:else if step === 3}
|
||||||
<section class="step-panel">
|
<section class="step-panel">
|
||||||
<div class="placeholder">
|
<!-- Preflight checks — caught early so the user can fix before M4 send. -->
|
||||||
<h3>Preflight</h3>
|
<div class="preflight-checks">
|
||||||
<p>Spam-Score, DNS-Checks und Empfänger-Übersicht folgen in M3/M8.</p>
|
<h3>Vor dem Versand</h3>
|
||||||
<p class="hint">
|
<ul class="check-list">
|
||||||
Empfänger: <strong>{audience.estimatedCount}</strong><br />
|
<li class:ok={subject.trim().length > 0} class:warn={!subject.trim()}>
|
||||||
Betreff: <strong>{subject || '—'}</strong><br />
|
<span class="icon">{subject.trim() ? '✓' : '!'}</span>
|
||||||
Absender: <strong>{fromName} <{fromEmail}></strong>
|
Betreff {subject.trim() ? 'gesetzt' : 'fehlt'}
|
||||||
</p>
|
{#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>
|
</div>
|
||||||
|
|
||||||
|
{#if settings}
|
||||||
|
<PreviewTabs campaign={{ subject, preheader, fromName, fromEmail }} {content} {settings} />
|
||||||
|
{:else}
|
||||||
|
<p class="loading">Lade Einstellungen …</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if step === 4}
|
{:else if step === 4}
|
||||||
<section class="step-panel">
|
<section class="step-panel">
|
||||||
|
|
@ -374,12 +421,6 @@
|
||||||
color: var(--color-text, #0f172a);
|
color: var(--color-text, #0f172a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder .hint {
|
|
||||||
margin-top: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #6366f1;
|
background: #6366f1;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
@ -403,4 +444,79 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue