---
name: mailforge-section-authoring
description: Section-Templates für MailForge — wie man wiederverwendbare MJML-Bausteine pro Brand anlegt, editiert oder seedet, und wie die drei `bodyMode`s (`tiptap` / `mjml_raw` / `html_raw`) sich verhalten. Nutze diesen Skill bei Änderungen an `sectionTemplates`-Daten, `scripts/seed-section-templates.ts`, `src/lib/email/inject-sections.ts`, `src/lib/email/sanitize-section-html.ts`, oder `src/lib/ai/sections-to-tiptap.ts`. Triggert auch bei "neue Section anlegen", "App-Download-Block", "FAQ-Section für ITS", "warum funktioniert KI-Suggest nicht in dieser Section", "{{TIPTAP_HTML}} wird nicht ersetzt", "Section verschwindet nach Re-Seed". Nicht für komplette Mail-Layouts (das macht das LLM via `produce_email`) oder für globale Brand-Tags wie `{{LOGO_URL}}` (→ mailforge-send-pipeline).
---

# MailForge Section-Authoring

Sections sind **wiederverwendbare MJML-Bausteine** (Kontakt, App-Download, Testimonial, FAQ, Custom). Sie werden **post-LLM** in den Mail-Tree gespliced — das LLM produziert nur den Core-Body. Pro Brand gibt es einen eigenen Pool. Section-Wahl + Inhalt liegen beim User im Briefing-Panel.

## Mental Model

```
LLM produce_email
   ↓ liefert MJML-Core-Body + image_brief
finaler Composer (render-template.ts + inject-sections.ts)
   ↓
   ├─ render-template.ts → Brand-Tags ({{LOGO_URL}}, {{IMPRINT_URL}}, ...)
   └─ injectExtraSections() → splict jede gewählte Section in den Tree
          ↓
          Per Section: bodyMode-spezifisches Rendering
          ↓
   fertiges MJML → mjml2html() → HTML + Plain-Text
```

Sections sind **nicht** Teil des LLM-Outputs. Das LLM weiß von ihrer Existenz nichts und soll sie nicht erfinden.

## Die drei `bodyMode`s

`sectionTemplates.bodyMode` ist ein Enum mit drei Werten. Sie verhalten sich **fundamental anders** im Editor, im KI-Suggest, und im Render-Path.

| `bodyMode` | `{{TIPTAP_HTML}}`-Slot im MJML? | UI-Editor? | KI-Suggest? | Use-Case |
|---|---|---|---|---|
| `tiptap` | **JA, erforderlich** | Inline-WYSIWYG (TipTap 2) | aktiv | Standardfall — Text-/HTML-Content den der User schreibt oder das LLM vorschlägt |
| `mjml_raw` | NEIN | nicht editierbar (oder Raw-MJML-Textarea) | **deaktiviert** | Komplexe MJML-Strukturen die der User direkt liefert (z.B. Pricing-Tabelle, Multi-Column-Layout) |
| `html_raw` | NEIN | Raw-HTML-Textarea | **deaktiviert** | Importierter HTML-Block aus einem externen Tool — wird in `<mj-raw>` gewrapped + sanitized |

### Was `bodyMode` konkret entscheidet

- **`tiptap`:**
  - `mjmlBlock` MUSS `{{TIPTAP_HTML}}` enthalten — sonst landet User-Content nirgendwo.
  - `injectExtraSections` (`src/lib/email/inject-sections.ts:62`) macht `.replace(/\{\{TIPTAP_HTML\}\}/g, contentHtml)`.
  - `contentHtml` kommt entweder vom UI-Editor (TipTap-Doc → `sections-to-tiptap.ts::docToHtml()`) oder vom LLM-Suggest.
  - UI zeigt einen Inline-Editor (gerichtet via postMessage-Bridge in `src/lib/email/inline-editor.ts`).

- **`mjml_raw`:**
  - `mjmlBlock` IST das vollständige MJML — kein Slot, keine Substitution.
  - Optional: zusätzliche `<mj-style>`-Blöcke werden in `<mj-head>` gemerged (siehe `inject-sections.ts` Kommentar).
  - UI rendert eine MJML-Textarea (oder zeigt nur Vorschau).
  - KI-Suggest-Button ist **disabled** — Suggest würde das MJML überschreiben.

- **`html_raw`:**
  - User liefert HTML, wir wrappen in `<mj-raw>{sanitized}</mj-raw>`.
  - `sanitizeSectionHtml()` (`src/lib/email/sanitize-section-html.ts:109`) strippt gefährliche Tags via DOMPurify.
  - Helper `wrapAsMjRaw()` (gleiche Datei, ~Zeile 133) macht das ein-Schritt.
  - UI = Raw-HTML-Textarea. KI-Suggest **disabled**.

## Eine neue Section anlegen — der pragmatische Pfad

### Variante A: Brand-spezifisch, über UI

1. Settings → Brand wählen → Section-Templates
2. "Neue Section" → Name (`label`), `kind` (`contact` / `app_download` / `testimonial` / `faq` / `custom`), `bodyMode` wählen
3. MJML / HTML einfügen, je nach Modus
4. Save → in `section_templates` als Brand-Eintrag

Das ist **per-Brand-Custom-Section** und braucht keine Code-Änderung.

### Variante B: System-weite Section in den Seed aufnehmen

Wenn die Section **für jede Brand** beim ersten Boot da sein soll, ergänzt du sie in `scripts/seed-section-templates.ts`:

```typescript
// Beispiel — siehe Bestand für exakte Struktur
{
  kind: 'app_download',
  label: 'App-Download',
  bodyMode: 'tiptap',
  sortOrder: 30,
  mjmlBlock: `
    <mj-section background-color="#fafafa" padding="16px">
      <mj-column>
        <mj-text font-size="13px" color="#52525b" line-height="1.6" padding="0">{{TIPTAP_HTML}}</mj-text>
      </mj-column>
    </mj-section>
  `,
  // ggf. defaultContentHtml für tiptap (initialer Inhalt)
  // ggf. promptHint für KI-Suggest (was soll der Suggest produzieren?)
}
```

Dann:

```bash
npm run db:seed-sections    # lokal idempotent ergänzen
# Im Container läuft der Seed beim Entrypoint automatisch
```

**Wichtig:** Der Seed ist **idempotent** (Match per `(brandId, kind, label)`). Existierende Sections werden **nicht** überschrieben — wenn du den `mjmlBlock` einer schon geseedeten Section ändern willst, muss die Seed-Funktion das explizit als Update behandeln (aktuell macht sie das **nicht**). Workaround:

```bash
# 1. Section in der UI löschen, dann neu seeden
# 2. ODER: Seed-Funktion temporär auf "update if exists" umstellen
```

### Variante C: Brand-Cascade beachten

`sectionTemplates.brandId` ist **cascade on delete**. Brand löschen → alle ihre Templates weg. Nicht zur Säuberung nutzen, wenn du Templates erhalten willst — vorher backup.

## `{{TIPTAP_HTML}}` — die häufigste Fehlerquelle

Wenn ein User-Editor-Inhalt in der finalen Mail fehlt:

1. Hat die Section `bodyMode='tiptap'`? Nur dann macht der Slot Sinn.
2. Enthält `mjmlBlock` **wörtlich** `{{TIPTAP_HTML}}` (case-sensitive, keine Spaces)?
3. Wird `injectExtraSections()` überhaupt aufgerufen (also kommt die Section im finalen MJML an)?
4. Ist `contentHtml` non-empty? `docToHtml()` returned `""` für ein leeres TipTap-Doc.

Wenn das Template aus dem LLM-Suggest kommt: `suggest-section.ts` schreibt **nur** den TipTap-HTML-Output, nicht den MJML-Wrapper. Das Wrapper-MJML bleibt unverändert.

## `sanitize-section-html.ts` für `html_raw`

DOMPurify-Pipeline mit zwei Stages:

1. **Stage 1:** Pre-Processing (z.B. Style-Tags extrahieren in `<mj-style>`-Hooks)
2. **Stage 2:** DOMPurify mit explizit erlaubter Tag-/Attr-Liste

Wenn ein erlaubtes Element gestrippt wird → Allow-List in `sanitize-section-html.ts` ergänzen (sparsam, jedes Element ist eine potenzielle XSS-Lücke).

`wrapAsMjRaw()` ist der Convenience-Wrapper: nimmt rohes HTML, returnt `<mj-raw>{sanitized}</mj-raw>` plus optional gemergeter `<mj-style>`-Inhalt.

## TipTap-Doc → HTML

`src/lib/ai/sections-to-tiptap.ts::docToHtml(doc)`:

- **Deterministisch** — gleiche Eingabe = gleiches HTML, immer.
- Whitelist von Node-Types: heading (h2/h3), paragraph, bulletList/listItem, bold, italic.
- HTML ist **safe by construction** (keine Attributes außer in der Whitelist) — kein zusätzliches Sanitize nötig vor dem MJML-Inject.
- Wird sowohl im Editor (Live-Preview) als auch im Server (Render-vor-Send) aufgerufen.

`docFromBriefSections(briefSections)` (gleiche Datei): macht aus dem LLM-Output `BriefSections` einen TipTap-Doc — wird für den Initial-Editor-Load nach `produce_email` genutzt.

## KI-Suggest pro Section

Der Suggest-Button (`src/lib/ai/suggest-section.ts`) ist nur für `bodyMode='tiptap'` aktiv. Input:

- Brand-Kontext (Voice, Imprint-URLs etc.)
- Section-Type (`kind`)
- Optional: `promptHint` aus dem Template
- Optional: existierender Editor-Inhalt (zum Verbessern statt Neugenerieren)

Output ist **plain TipTap-HTML** — kein MJML, keine `<mj-…>`-Tags.

Wenn ein User fragt "Warum kann ich für diese Section nichts vorschlagen lassen?" → `bodyMode` ist `mjml_raw` oder `html_raw`. Suggest macht da keinen Sinn (würde User-MJML überschreiben).

## Häufige Symptome → Fix

| Symptom | Ursache | Fix |
|---|---|---|
| Section taucht in Vorschau nicht auf | Nicht in der gewählten Section-Liste der Campaign | Briefing → Sections-Tab: aktivieren |
| `{{TIPTAP_HTML}}` als Literal in finaler Mail | Slot existiert, aber `contentHtml=""` | Editor-Inhalt prüfen, ggf. `defaultContentHtml` setzen |
| KI-Suggest-Button greyed out | `bodyMode` ≠ `tiptap` | bewusst so. Wenn doch nötig: bodyMode wechseln (vorsicht: MJML/HTML migrieren!) |
| Re-Seed überschreibt User-Edit nicht | Seed ist idempotent per `(brandId, kind, label)` | Section löschen + neu seeden, oder Seed temporär auf Update umstellen |
| HTML-Block aus externem Tool zerschossen | DOMPurify hat Tags/Attrs gestrippt | `sanitize-section-html.ts` Allow-List vorsichtig erweitern |
| Section nach Brand-Löschung weg | `onDelete: cascade` | bewusst so. Vor Brand-Löschen: Templates exportieren / auf andere Brand kopieren |

## Cross-References

- Was passiert **nach** Section-Inject (Brand-Tags, MJML→HTML-Rendering): `mailforge-send-pipeline` Skill.
- Schema-Definition `sectionTemplates`, Cascade-Verhalten, FK-Constraints: `mailforge-data-layer` Skill.
- Suggest-Prompt-Konvention, was darf das LLM ausgeben: `mailforge-ai-pipeline` Skill.

## Kritische Files

| Datei | Verantwortung |
|---|---|
| `src/lib/db/schema.ts::sectionTemplates` | Schema + bodyMode-Enum |
| `scripts/seed-section-templates.ts` | Idempotenter Default-Seed pro Brand |
| `src/lib/email/inject-sections.ts` | Splict gewählte Sections in den MJML-Tree, ersetzt `{{TIPTAP_HTML}}` |
| `src/lib/email/sanitize-section-html.ts` | DOMPurify-Pipeline für `html_raw`, `wrapAsMjRaw()` |
| `src/lib/ai/sections-to-tiptap.ts` | `docToHtml`, `docFromBriefSections` — deterministische TipTap-Serialisierung |
| `src/lib/ai/suggest-section.ts` | KI-Suggest für `tiptap`-Sections |
| `src/components/editor/MarketingEditor.tsx` | TipTap-Wrapper im UI |
| `src/lib/email/inline-editor.ts` | postMessage-Bridge zwischen UI und Editor-iframe |
