Merge #16: Schreiben als E-Mail uebernehmen

Behebt Issue #5: Umschalter Brief/E-Mail im Schreiben-Editor, mailto
fuer kurze Texte, .eml immer, Ablage als gekennzeichnete E-Mail.

Closes #5
This commit is contained in:
2026-08-23 09:18:59 +00:00
15 changed files with 877 additions and 37 deletions
+49 -1
View File
@@ -26,6 +26,53 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [1.5.0] – 2026-08-23
### Hinzugefügt
- **Schreiben als E-Mail übernehmen**
([#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5)).
Vieles geht heute per E-Mail hinaus, nicht per Brief. Im Schreiben-Editor
gibt es jetzt den Umschalter **Als Brief / Als E-Mail**.
Die E-Mail-Fassung wird aus dem — womöglich schon bearbeiteten —
Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, die
gehören in den Brief und nicht in die Mail. **Der Bestellungsabsatz
bleibt drin** — ohne ihn kommt die Auskunft nicht. Betreff und Text sind
einzeln kopierbar.
- **Zwei Wege ins Mailprogramm.** Die offene Entscheidung aus dem Issue
(mailto oder .eml) ist mit *beides* beantwortet:
**„Im Mailprogramm öffnen"** (mailto) für kurze Texte — der Knopf
verschwindet, sobald der kodierte Link 1800 Zeichen übersteigt. mailto
übergibt den Text in der Adresszeile, deren Länge nirgends zugesichert
ist; ein stillschweigend halbierter Brief fällt erst beim Empfänger auf.
**„Als E-Mail-Entwurf (.eml)"** immer: eine Datei, die sich im
Mailprogramm als fertiger Entwurf öffnet — Adresse, Betreff und voller
Text garantiert vollständig, mit korrekt kodierten Umlauten. Die Datei
erfindet bewusst keinen Absender: den trägt das Mailprogramm beim
Senden selbst ein.
Beide Knöpfe erscheinen nur, wenn eine E-Mail-Adresse da ist — aus dem
gewählten Kontakt übernommen oder von Hand eingetragen. Ohne Adresse
bleibt das Kopieren.
- **Die Ablage kennt den Unterschied.** Eine als E-Mail abgelegte
Nachricht heißt in der Akte „E-Mail: …" und die PDF-Fassung trägt die
Empfängeradresse im Kopf — abgelegt wird die E-Mail-Fassung, nicht der
Brieftext. In der Akte soll stehen, was tatsächlich hinausging.
### Geprüft
- 20 Einheitstests: die Ableitung der E-Mail-Fassung (nichts geht
stillschweigend verloren, der Bestellungsabsatz bleibt), die
mailto-Längengrenze **nach** der Kodierung, das .eml-Format samt
Umlauten nach RFC 2047 und quoted-printable.
- Vier End-to-End-Tests: Fassung ohne Briefkopf, .eml-Download, kein
Mailknopf ohne Adresse, Ablage als gekennzeichnete E-Mail.
## [1.4.0] – 2026-08-23 ## [1.4.0] – 2026-08-23
### Hinzugefügt ### Hinzugefügt
@@ -768,7 +815,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
--- ---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...HEAD
[1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0
[1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0 [1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0
[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0 [1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0 [1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0
+3 -3
View File
@@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind.
| | | nachzuzählen mit | | | | nachzuzählen mit |
|---|---:|---| |---|---:|---|
| Zeilen TypeScript/TSX | 76.610 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Zeilen TypeScript/TSX | 78.486 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
| Seiten | 81 | `find src/app -name page.tsx \| wc -l` | | Seiten | 81 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 570 | `npm test` | | Unit-Tests (Vitest) | 590 | `npm test` |
| End-to-End-Tests (Playwright) | 73 | `npm run test:e2e` — 40 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | End-to-End-Tests (Playwright) | 81 | `npm run test:e2e` — 44 Tests über zwei Geräteprofile, geräteabhängige übersprungen |
| TypeScript-Fehler | 0 | `npm run typecheck` | | TypeScript-Fehler | 0 | `npm run typecheck` |
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es
+7 -7
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.4.0 git checkout v1.5.0
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.4.0 git describe --tags # v1.5.0
``` ```
--- ---
@@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up
```bash ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.4.0","commit":"..."} # {"status":"bereit","version":"1.5.0","commit":"..."}
``` ```
Kommt hier nichts, sieh in die Logs: Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.4.0",...} # {"status":"bereit","version":"1.5.0",...}
``` ```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.4.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.5.0 · <commit>`.
--- ---
@@ -875,7 +875,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung | | Aufruf | Wirkung |
|---|---| |---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.4.0` | auf eine bestimmte Fassung | | `./scripts/update.sh v1.5.0` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/main
# 3. Umschalten # 3. Umschalten
git checkout v1.4.0 git checkout v1.5.0
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben. als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.4.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.5.0** ([CHANGELOG](../CHANGELOG.md))
--- ---
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Schreiben als E-Mail — gegen die echte Anwendung.
*
* Geprüft wird der Weg, den die Einheitstests nicht sehen: Umschalten auf
* E-Mail, die abgeleitete Fassung ohne Briefkopf, der .eml-Download und
* die Ablage, die das Schreiben als E-Mail kennzeichnet.
*/
const KONTAKT = 'Prüfhausen Versicherung (E-Mail-Test)'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } })
await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } })
await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } })
})
}
test.describe('Schreiben als E-Mail', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: KONTAKT,
organisation: KONTAKT,
rolle: 'SONSTIGES',
email: 'service@pruefhausen.example',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('leitet die E-Mail-Fassung ohne Briefkopf ab, mit Bestellungsabsatz', async ({
page,
}) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
// Kontakt wählen - bringt die E-Mail-Adresse mit.
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await expect(page.locator('input#empfaengerEmail')).toHaveValue(
'service@pruefhausen.example',
)
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const mailtext = await page.locator('textarea#emailText').inputValue()
// Beginnt mit der Anrede - kein Absenderblock, keine Datumszeile.
expect(mailtext.startsWith('Sehr geehrte')).toBe(true)
expect(mailtext).not.toMatch(/^\d{1,2}\.\d{1,2}\.\d{4}$/m)
// Der Nachweis der Bestellung bleibt drin.
expect(mailtext).toMatch(/Betreuungsgericht|bestellt/i)
})
test('lädt den .eml-Entwurf herunter', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.getByRole('button', { name: 'Als E-Mail' }).click()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' }).click(),
])
expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.eml$/)
})
test('zeigt ohne Adresse keine Mailprogramm-Knöpfe', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
// Kopieren geht immer, die Programm-Knöpfe nicht.
await expect(page.getByRole('button', { name: 'Text kopieren' }).last()).toBeVisible()
await expect(page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Im Mailprogramm öffnen' })).toHaveCount(0)
await expect(page.getByText(/Trag oben eine E-Mail-Adresse ein/)).toBeVisible()
})
test('legt die E-Mail als solche gekennzeichnet in die Akte', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` })
await page.locator('input#betreff').fill('Prüfhausen Testanfrage')
await page.getByRole('button', { name: 'Als E-Mail' }).click()
await page.getByRole('button', { name: 'In die Akte legen' }).click()
await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({
timeout: 30_000,
})
// In der Akte als E-Mail erkennbar, nicht als Brief.
await page.goto('/dokumente?richtung=AUSGANG')
await expect(page.locator('body')).toContainText('E-Mail: Prüfhausen Testanfrage')
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.4.0", "version": "1.5.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { emailFassung, siehtAusWieEmail } from '@/lib/schreiben/email'
import { emlErzeugen } from '@/lib/schreiben/eml'
import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten'
import { heuteAlsTag } from '@/lib/fristen/datum'
/**
* Das Schreiben als .eml-Entwurf herunterladen.
*
* Gleicher Aufbau wie der Word-Download (../docx/route.ts): POST, weil der
* Text mitkommt und nicht in die Adresszeile gehoert; Zugriff wird hier
* geprueft, weil sich eine Route unmittelbar aufrufen laesst.
*/
export const dynamic = 'force-dynamic'
export async function POST(anfrage: Request): Promise<Response> {
let formular: FormData
try {
formular = await anfrage.formData()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const betreuteId = String(formular.get('betreuteId') ?? '')
const rohArt = String(formular.get('art') ?? '')
const text = String(formular.get('text') ?? '')
const betreff = String(formular.get('betreff') ?? '').trim()
const adresse = String(formular.get('empfaengerEmail') ?? '').trim()
const empfaenger = String(formular.get('empfaengerName') ?? '').trim()
if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) {
return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 })
}
if (!siehtAusWieEmail(adresse)) {
return NextResponse.json(
{ fehler: 'Für diesen Empfänger ist keine E-Mail-Adresse hinterlegt.' },
{ status: 400 },
)
}
try {
await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen')
} catch (ausnahme) {
if (ausnahme instanceof ZugriffVerweigert) {
return NextResponse.json({ fehler: ausnahme.message }, { status: 403 })
}
throw ausnahme
}
const fassung = emailFassung(text, betreff)
const datei = emlErzeugen({ an: adresse, betreff: fassung.betreff, text: fassung.text })
const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, heuteAlsTag(), 'eml')
return new NextResponse(new Uint8Array(datei), {
headers: {
'Content-Type': 'message/rfc822',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': String(datei.length),
'Cache-Control': 'no-store, must-revalidate',
},
})
}
+9 -1
View File
@@ -79,7 +79,15 @@ export default async function SchreibenSeite({
const kontakte = await db.kontakt.findMany({ const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true, strasse: true, plz: true, ort: true }, select: {
id: true,
name: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
email: true,
},
orderBy: [{ organisation: 'asc' }, { name: 'asc' }], orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 200, take: 200,
}) })
+176 -19
View File
@@ -10,6 +10,7 @@ import {
FileDown, FileDown,
FolderInput, FolderInput,
Info, Info,
Mail,
UserPlus, UserPlus,
} from 'lucide-react' } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
@@ -26,6 +27,7 @@ import {
type SchreibenZustand, type SchreibenZustand,
} from '@/server/actions/schreiben' } from '@/server/actions/schreiben'
import type { SchreibenArt } from '@/lib/schreiben/arten' import type { SchreibenArt } from '@/lib/schreiben/arten'
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
export type KontaktAuswahl = { export type KontaktAuswahl = {
id: string id: string
@@ -34,6 +36,7 @@ export type KontaktAuswahl = {
strasse: string | null strasse: string | null
plz: string | null plz: string | null
ort: string | null ort: string | null
email: string | null
} }
/** /**
@@ -67,6 +70,10 @@ export function SchreibenEditor({
const [betreff, setBetreff] = React.useState(entwurf.betreff) const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('')
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false)
const [mailKopiert, setMailKopiert] = React.useState(false)
const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10)) const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10))
const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm) const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
const [kopiert, setKopiert] = React.useState(false) const [kopiert, setKopiert] = React.useState(false)
@@ -94,6 +101,8 @@ export function SchreibenEditor({
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} /> <input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
<input type="hidden" name="vertragId" value={vertragId ?? ''} /> <input type="hidden" name="vertragId" value={vertragId ?? ''} />
<input type="hidden" name="vorgangId" value={vorgangId ?? ''} /> <input type="hidden" name="vorgangId" value={vorgangId ?? ''} />
<input type="hidden" name="versandweg" value={versandweg} />
<input type="hidden" name="empfaengerEmail" value={empfaengerEmail} />
</> </>
) )
} }
@@ -107,6 +116,7 @@ export function SchreibenEditor({
.filter(Boolean) .filter(Boolean)
.join('\n'), .join('\n'),
) )
setEmpfaengerEmail(kontakt.email ?? '')
} }
async function textKopieren() { async function textKopieren() {
@@ -121,6 +131,109 @@ export function SchreibenEditor({
const abgelegt = Boolean(ablageZustand.erfolg) const abgelegt = Boolean(ablageZustand.erfolg)
/**
* Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf.
*
* Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet
* hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf.
*/
function EmailAusgabe() {
const fassung = emailFassung(text, betreff)
const adresseGueltig = siehtAusWieEmail(empfaengerEmail)
const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null
async function kopieren(inhalt: string, merker: (wert: boolean) => void) {
try {
await navigator.clipboard.writeText(inhalt)
merker(true)
window.setTimeout(() => merker(false), 2500)
} catch {
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
}
}
return (
<div className="space-y-4 rounded-md border p-4">
<Feld
id="emailBetreff"
beschriftung="Betreff der E-Mail"
erklaerung="Aus der Betreffzeile des Briefes übernommen."
>
<div className="flex gap-2">
<Input id="emailBetreff" value={fassung.betreff} readOnly className="flex-1" />
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.betreff, setBetreffKopiert)}
>
<Copy aria-hidden="true" />
{betreffKopiert ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
</Feld>
<Feld
id="emailText"
beschriftung="Text der E-Mail"
erklaerung="Ohne Absenderblock, Anschrift und Datumszeile — die gehören in den Brief, nicht in die Mail. Der Nachweis der Bestellung bleibt drin."
>
<div className="space-y-2">
<Textarea
id="emailText"
value={fassung.text}
readOnly
rows={14}
className="font-mono text-sm leading-relaxed"
/>
<Button
type="button"
variant="outline"
onClick={() => void kopieren(fassung.text, setMailKopiert)}
>
<Copy aria-hidden="true" />
{mailKopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
</div>
</Feld>
<div className="flex flex-wrap items-center gap-2">
{mailto && (
<Button asChild variant="outline">
<a href={mailto}>
<Mail aria-hidden="true" />
Im Mailprogramm öffnen
</a>
</Button>
)}
{adresseGueltig && (
<form action="/schreiben/eml" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als E-Mail-Entwurf (.eml)
</Button>
</form>
)}
</div>
{!adresseGueltig && (
<p className="text-sm text-muted-foreground">
Trag oben eine E-Mail-Adresse ein, dann erscheinen hier die Knöpfe fürs
Mailprogramm. Kopieren geht auch ohne.
</p>
)}
{adresseGueltig && !mailto && (
<p className="text-sm text-muted-foreground">
Der Text ist zu lang für einen mailto-Link — manche Mailprogramme würden ihn
stillschweigend abschneiden. Nimm die .eml-Datei, sie trägt den vollen Text.
</p>
)}
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{warnungen.length > 0 && ( {warnungen.length > 0 && (
@@ -193,6 +306,21 @@ export function SchreibenEditor({
/> />
</Feld> </Feld>
<Feld
id="empfaengerEmail"
beschriftung="E-Mail-Adresse"
erklaerung="Nur nötig, wenn das Schreiben als E-Mail hinausgehen soll. Wird aus dem gewählten Kontakt übernommen."
>
<Input
id="empfaengerEmail"
type="email"
value={empfaengerEmail}
onChange={(ereignis) => setEmpfaengerEmail(ereignis.target.value)}
maxLength={200}
placeholder="service@versicherung.de"
/>
</Feld>
{empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && ( {empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && (
<form action={kontaktAnlegen}> <form action={kontaktAnlegen}>
<VersteckteFelder /> <VersteckteFelder />
@@ -263,8 +391,32 @@ export function SchreibenEditor({
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Und jetzt?</CardTitle> <CardTitle className="text-base">Und jetzt?</CardTitle>
<CardDescription>
Wie geht es hinaus? Die Wahl bestimmt auch, wie das Schreiben in der Akte
gekennzeichnet wird.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="flex gap-2" role="group" aria-label="Versandweg">
<Button
type="button"
variant={versandweg === 'BRIEF' ? 'default' : 'outline'}
onClick={() => setVersandweg('BRIEF')}
>
<FileDown aria-hidden="true" />
Als Brief
</Button>
<Button
type="button"
variant={versandweg === 'EMAIL' ? 'default' : 'outline'}
onClick={() => setVersandweg('EMAIL')}
>
<Mail aria-hidden="true" />
Als E-Mail
</Button>
</div>
{versandweg === 'EMAIL' && <EmailAusgabe />}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Feld <Feld
id="versandAm" id="versandAm"
@@ -293,26 +445,31 @@ export function SchreibenEditor({
</Feld> </Feld>
</div> </div>
{/* Word-Datei: eigene Route, damit der Browser einen echten {versandweg === 'BRIEF' && (
Download bekommt. */} <>
<form action="/schreiben/docx" method="post" className="inline"> {/* Word-Datei: eigene Route, damit der Browser einen echten
<VersteckteFelder /> Download bekommt. */}
<Button type="submit" variant="outline"> <form action="/schreiben/docx" method="post" className="inline">
<FileDown aria-hidden="true" /> <VersteckteFelder />
Als Word-Datei <Button type="submit" variant="outline">
</Button> <FileDown aria-hidden="true" />
</form> Als Word-Datei
</Button>
</form>
<Alert variant="hinweis"> <Alert variant="hinweis">
<Info aria-hidden="true" /> <Info aria-hidden="true" />
<AlertDescription> <AlertDescription>
In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der
Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die Word-Datei Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die
ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '} Word-Datei ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab — dann <strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab —
lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann ablegen. dann lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann
</AlertDescription> ablegen.
</Alert> </AlertDescription>
</Alert>
</>
)}
{ablageZustand.fehler && ( {ablageZustand.fehler && (
<Alert variant="fehler"> <Alert variant="fehler">
+1 -1
View File
@@ -88,7 +88,7 @@ export function dateinameFuerSchreiben(
art: SchreibenArt, art: SchreibenArt,
empfaenger: string | null, empfaenger: string | null,
datum: Date, datum: Date,
endung: 'docx' | 'pdf', endung: 'docx' | 'pdf' | 'eml',
): string { ): string {
const tag = datum.toISOString().slice(0, 10) const tag = datum.toISOString().slice(0, 10)
+137
View File
@@ -0,0 +1,137 @@
/**
* Aus dem Brieftext eine E-Mail machen.
*
* Rein und ohne Datenbank, damit sich jede Regel einzeln testen laesst -
* und ohne "server-only", weil die mailto-Pruefung auch im Browser
* gebraucht wird.
*
* Der Kern ist emailFassung(): Sie nimmt den fertigen Brieftext und
* entfernt, was in einer E-Mail nichts verloren hat - Absenderblock,
* Anschriftenfeld, Datumszeile, Betreffzeile (der Betreff steht im
* Betrefffeld der Mail). Was bleibt, ist Anrede, Inhalt samt
* Bestellungsabsatz und Grussformel.
*
* Warum aus dem Brieftext ableiten statt getrennt erzeugen? Weil der
* Benutzer den Brief womoeglich schon bearbeitet hat. Eine getrennt
* erzeugte E-Mail wuerfe diese Aenderungen weg - und dann stuende im
* Brief etwas anderes als in der Mail, ohne dass es jemand merkt.
*/
/**
* Ab wann ist ein mailto-Link nicht mehr verlaesslich?
*
* mailto uebergibt den Text in der Adresszeile. Die Grenzen sind je nach
* Browser, Betriebssystem und Mailprogramm verschieden und nirgends
* zugesichert; ab etwa 2000 Zeichen wird es unzuverlaessig - unter
* Windows schneidet die Uebergabe an das Mailprogramm teils frueher ab.
*
* 1800 Zeichen NACH der URL-Kodierung lassen etwas Luft. Ein
* abgeschnittener Brief ist schlimmer als ein fehlender Knopf: Er faellt
* erst beim Empfaenger auf.
*/
export const MAILTO_HOECHSTLAENGE = 1800
export type EmailFassung = {
betreff: string
text: string
}
/** Erkennt die Betreffzeile des Briefes. */
function istBetreff(zeile: string): boolean {
return /^Betreff:/i.test(zeile.trim())
}
/** Erkennt die Datumszeile ("Ort, 23.08.2026" oder nur das Datum). */
function istDatumszeile(zeile: string): boolean {
return /^([A-Za-zÄÖÜäöüß .-]+,\s*)?\d{1,2}\.\d{1,2}\.\d{4}$/.test(zeile.trim())
}
/** Erkennt den Beginn des eigentlichen Inhalts: die Anrede. */
function istAnrede(zeile: string): boolean {
return /^Sehr geehrte/i.test(zeile.trim())
}
/**
* Leitet die E-Mail-Fassung aus dem Brieftext ab.
*
* Alles VOR der Anrede ist Briefkopf - Absender, Anschrift, Datum,
* Betreff - und faellt weg. Der Betreff wird dabei herausgelesen und
* getrennt zurueckgegeben; er gehoert ins Betrefffeld, nicht in den
* Rumpf.
*
* Findet sich keine Anrede (der Benutzer hat den Text frei umgebaut),
* wird NICHTS entfernt: Lieber ein Briefkopf zu viel in der Mail als
* stillschweigend verlorener Inhalt.
*/
export function emailFassung(brieftext: string, betreffVorgabe = ''): EmailFassung {
const zeilen = brieftext.split('\n')
let betreff = betreffVorgabe
const betreffZeile = zeilen.find(istBetreff)
if (betreffZeile) {
const roh = betreffZeile.replace(/^Betreff:\s*/i, '').trim()
if (roh) betreff = roh
}
const anredeIndex = zeilen.findIndex(istAnrede)
if (anredeIndex === -1) {
// Kein erkennbarer Aufbau - unveraendert lassen, nur den Betreff
// nicht doppelt fuehren.
const ohneBetreff = zeilen.filter((zeile) => !istBetreff(zeile))
return { betreff, text: bereinigen(ohneBetreff) }
}
// Ab der Anrede ist alles Inhalt. Datums- und Betreffzeilen davor sind
// damit automatisch draussen; zur Sicherheit filtern wir sie auch im
// Rumpf, falls der Text unueblich aufgebaut ist.
const rumpf = zeilen
.slice(anredeIndex)
.filter((zeile) => !istBetreff(zeile) && !istDatumszeile(zeile))
return { betreff, text: bereinigen(rumpf) }
}
/** Hoechstens eine Leerzeile am Stueck, keine am Anfang oder Ende. */
function bereinigen(zeilen: string[]): string {
const ergebnis: string[] = []
for (const zeile of zeilen) {
if (zeile.trim() === '' && ergebnis[ergebnis.length - 1]?.trim() === '') continue
ergebnis.push(zeile.trimEnd())
}
while (ergebnis[0]?.trim() === '') ergebnis.shift()
while (ergebnis[ergebnis.length - 1]?.trim() === '') ergebnis.pop()
return ergebnis.join('\n')
}
/**
* Baut den mailto-Link - oder gibt null zurueck, wenn er zu lang wuerde.
*
* null heisst fuer die Oberflaeche: den Knopf gar nicht erst anbieten.
* Ein Knopf, der einen halben Brief uebergibt, waere schlimmer als
* keiner - der Verlust faellt erst beim Empfaenger auf.
*/
export function mailtoLink(
adresse: string,
fassung: EmailFassung,
): string | null {
if (!adresse.trim()) return null
const link =
`mailto:${encodeURIComponent(adresse.trim())}` +
`?subject=${encodeURIComponent(fassung.betreff)}` +
`&body=${encodeURIComponent(fassung.text)}`
return link.length <= MAILTO_HOECHSTLAENGE ? link : null
}
/**
* Sehr grobe Formpruefung einer E-Mail-Adresse.
*
* Bewusst locker: Die Adresse kommt aus den eigenen Kontakten oder wird
* gleich im Mailprogramm sichtbar. Eine strenge Pruefung wuerde nur
* gueltige, aber ungewoehnliche Adressen abweisen.
*/
export function siehtAusWieEmail(wert: string | null | undefined): boolean {
if (!wert) return false
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(wert.trim())
}
+95
View File
@@ -0,0 +1,95 @@
import 'server-only'
/**
* Eine .eml-Datei bauen - der E-Mail-Entwurf zum Oeffnen im Mailprogramm.
*
* Warum .eml zusaetzlich zu mailto? mailto uebergibt den Text in der
* Adresszeile, und deren Laenge ist nirgends zugesichert. Die Briefe hier
* sind lang (Bestellungsabsatz, sechs Auskunftspunkte) - eine .eml-Datei
* traegt den vollen Text garantiert, egal wie lang.
*
* Das Format ist RFC 5322: Kopfzeilen, Leerzeile, Rumpf. Die Kopfzeile
* "X-Unsent: 1" bittet das Mailprogramm, die Datei als ENTWURF zu oeffnen
* statt als empfangene Nachricht - Outlook und Thunderbird respektieren
* das. Ein Programm, das sie ignoriert, zeigt die Mail eben als Nachricht
* mit Antwort-Moeglichkeit; auch damit laesst sich arbeiten.
*
* Ohne Absenderadresse ("From"): Die traegt das Mailprogramm beim Senden
* selbst ein. Eine erfundene Adresse hier wuerde je nach Programm sogar
* uebernommen - und dann ginge die Mail mit falschem Absender hinaus.
*/
/**
* Kodiert Text als quoted-printable (RFC 2045).
*
* Noetig fuer die Umlaute: .eml ist ein Textformat mit ASCII-Erwartung.
* Base64 ginge auch, aber quoted-printable bleibt lesbar, wenn jemand die
* Datei im Editor oeffnet - und bei einem Schreiben in einer
* Betreuungssache ist Nachvollziehbarkeit ein Wert fuer sich.
*/
export function quotedPrintable(text: string): string {
const bytes = Buffer.from(text.replace(/\r?\n/g, '\r\n'), 'utf8')
let ergebnis = ''
let zeilenlaenge = 0
for (const byte of bytes) {
let stueck: string
if (byte === 0x0d || byte === 0x0a) {
// Zeilenumbruch unkodiert - er strukturiert die Mail.
ergebnis += String.fromCharCode(byte)
zeilenlaenge = 0
continue
}
const istDruckbar =
(byte >= 33 && byte <= 126 && byte !== 61) || byte === 32 || byte === 9
if (istDruckbar) {
stueck = String.fromCharCode(byte)
} else {
stueck = `=${byte.toString(16).toUpperCase().padStart(2, '0')}`
}
// RFC 2045: hoechstens 76 Zeichen je Zeile, weiche Umbrueche mit "=".
if (zeilenlaenge + stueck.length > 75) {
ergebnis += '=\r\n'
zeilenlaenge = 0
}
ergebnis += stueck
zeilenlaenge += stueck.length
}
return ergebnis
}
/**
* Kodiert eine Kopfzeile mit Umlauten (RFC 2047, "encoded word").
*
* "Betreff: Rückkaufswert" wuerde als rohe Kopfzeile von manchen
* Programmen verstuemmelt. ASCII-Betreffs bleiben unkodiert - lesbarer.
*/
export function kopfzeileKodieren(text: string): string {
// eslint-disable-next-line no-control-regex
if (!/[^\x20-\x7E]/.test(text)) return text
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`
}
export type EmlEingabe = {
an: string
betreff: string
text: string
}
/** Baut die .eml-Datei als Buffer. */
export function emlErzeugen(eingabe: EmlEingabe): Buffer {
const kopf = [
`To: ${kopfzeileKodieren(eingabe.an)}`,
`Subject: ${kopfzeileKodieren(eingabe.betreff)}`,
'X-Unsent: 1',
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
].join('\r\n')
return Buffer.from(`${kopf}\r\n\r\n${quotedPrintable(eingabe.text)}\r\n`, 'utf8')
}
+1 -1
View File
@@ -17,7 +17,7 @@
*/ */
/** Muss zur "version" in der package.json passen. */ /** Muss zur "version" in der package.json passen. */
export const VERSION = '1.4.0' export const VERSION = '1.5.0'
/** /**
* Kurzer Git-Stand, beim Bauen hineingereicht: * Kurzer Git-Stand, beim Bauen hineingereicht:
+21 -2
View File
@@ -15,6 +15,7 @@ import {
type SchreibenArt, type SchreibenArt,
} from '@/lib/schreiben/arten' } from '@/lib/schreiben/arten'
import { briefPdfErzeugen } from '@/lib/schreiben/pdf' import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
import { emailFassung } from '@/lib/schreiben/email'
/** /**
* Server Actions rund um erzeugte Schreiben. * Server Actions rund um erzeugte Schreiben.
@@ -51,6 +52,9 @@ const ablegenSchema = z.object({
wiedervorlageAm: z.string().trim().default(''), wiedervorlageAm: z.string().trim().default(''),
vertragId: z.string().trim().default(''), vertragId: z.string().trim().default(''),
vorgangId: z.string().trim().default(''), vorgangId: z.string().trim().default(''),
/** "BRIEF" oder "EMAIL" - bestimmt Fassung und Kennzeichnung. */
versandweg: z.enum(['BRIEF', 'EMAIL']).default('BRIEF'),
empfaengerEmail: z.string().trim().max(200).default(''),
}) })
/** /**
@@ -73,6 +77,8 @@ export async function schreibenAblegen(
betreff: daten.get('betreff'), betreff: daten.get('betreff'),
text: daten.get('text'), text: daten.get('text'),
empfaengerName: daten.get('empfaengerName') ?? '', empfaengerName: daten.get('empfaengerName') ?? '',
versandweg: daten.get('versandweg') ?? 'BRIEF',
empfaengerEmail: daten.get('empfaengerEmail') ?? '',
versandAm: daten.get('versandAm') ?? '', versandAm: daten.get('versandAm') ?? '',
wiedervorlageAm: daten.get('wiedervorlageAm') ?? '', wiedervorlageAm: daten.get('wiedervorlageAm') ?? '',
vertragId: daten.get('vertragId') ?? '', vertragId: daten.get('vertragId') ?? '',
@@ -110,7 +116,20 @@ export async function schreibenAblegen(
if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' } if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
// --- Datei zuerst ------------------------------------------------------- // --- Datei zuerst -------------------------------------------------------
const pdf = await briefPdfErzeugen(werte.text, versandAm) // Bei einer E-Mail wird die E-Mail-Fassung abgelegt, nicht der
// Brieftext: In der Akte soll stehen, was tatsaechlich hinausging -
// samt Empfaengeradresse im Kopf der PDF.
const istEmail = werte.versandweg === 'EMAIL'
const fassung = istEmail ? emailFassung(werte.text, werte.betreff) : null
const ablageText = fassung
? [
`E-Mail an: ${werte.empfaengerEmail || werte.empfaengerName || '[Adresse]'}`,
`Betreff: ${fassung.betreff}`,
'',
fassung.text,
].join('\n')
: werte.text
const pdf = await briefPdfErzeugen(ablageText, versandAm)
const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf') const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf')
const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf') const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf')
@@ -118,7 +137,7 @@ export async function schreibenAblegen(
const dokument = await db.dokument.create({ const dokument = await db.dokument.create({
data: { data: {
betreuteId, betreuteId,
titel: werte.betreff.slice(0, 200), titel: (istEmail ? `E-Mail: ${werte.betreff}` : werte.betreff).slice(0, 200),
art: 'SCHREIBEN', art: 'SCHREIBEN',
richtung: 'AUSGANG', richtung: 'AUSGANG',
schreibenVom: versandAm, schreibenVom: versandAm,
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
MAILTO_HOECHSTLAENGE,
emailFassung,
mailtoLink,
siehtAusWieEmail,
} from '@/lib/schreiben/email'
import { emlErzeugen, kopfzeileKodieren, quotedPrintable } from '@/lib/schreiben/eml'
/**
* Schreiben als E-Mail.
*
* Zwei Gefahren stehen im Mittelpunkt: dass beim Umbau vom Brief zur Mail
* stillschweigend Inhalt verloren geht (vor allem der Bestellungsabsatz),
* und dass ein mailto-Link einen halben Brief übergibt, ohne dass es
* jemand merkt.
*/
const BRIEF = [
'Florian Wilde',
'Musterstraße 1',
'96450 Coburg',
'',
'Allianz Lebensversicherungs-AG',
'[Straße und Hausnummer]',
'[PLZ und Ort]',
'',
'23.08.2026',
'',
'Betreff: Auskunft über Rückkaufswert — Franziska Wilde, geb. 23.09.1963',
'',
'Sehr geehrte Damen und Herren,',
'',
'ich bin durch Beschluss des Amtsgerichts Lichtenfels (Az. XVII 355/25) ' +
'zum rechtlichen Betreuer bestellt.',
'',
'für ein laufendes Verfahren benötige ich folgende Auskünfte.',
'',
'Mit freundlichen Grüßen',
'Florian Wilde',
].join('\n')
describe('E-Mail-Fassung aus dem Brief', () => {
it('entfernt Absender, Anschrift, Datum und Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('Musterstraße')
expect(fassung.text).not.toContain('[Straße und Hausnummer]')
expect(fassung.text).not.toContain('23.08.2026')
expect(fassung.text).not.toContain('Betreff:')
})
it('beginnt mit der Anrede und behält den Bestellungsabsatz', () => {
// Der Absatz ist der Grund, warum überhaupt geantwortet wird.
const fassung = emailFassung(BRIEF)
expect(fassung.text.startsWith('Sehr geehrte Damen und Herren,')).toBe(true)
expect(fassung.text).toContain('Amtsgerichts Lichtenfels')
expect(fassung.text).toContain('Mit freundlichen Grüßen')
})
it('zieht den Betreff aus der Betreffzeile', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.betreff).toContain('Auskunft über Rückkaufswert')
expect(fassung.betreff).toContain('Franziska Wilde')
})
it('lässt einen frei umgebauten Text unangetastet', () => {
// Ohne erkennbare Anrede wird nichts entfernt: Lieber ein Briefkopf zu
// viel in der Mail als stillschweigend verlorener Inhalt.
const frei = 'Hallo Frau Maier,\nhier die gewünschten Unterlagen.\nViele Grüße'
const fassung = emailFassung(frei, 'Unterlagen')
expect(fassung.text).toContain('Hallo Frau Maier,')
expect(fassung.text).toContain('Viele Grüße')
expect(fassung.betreff).toBe('Unterlagen')
})
it('faltet mehrfache Leerzeilen zusammen', () => {
const fassung = emailFassung(BRIEF)
expect(fassung.text).not.toContain('\n\n\n')
})
})
describe('mailto-Link', () => {
const fassung = { betreff: 'Kurzer Betreff', text: 'Kurzer Text.' }
it('baut den Link mit Adresse, Betreff und Text', () => {
const link = mailtoLink('service@allianz.de', fassung)
expect(link).not.toBeNull()
expect(link).toContain('mailto:service%40allianz.de')
expect(link).toContain('subject=Kurzer%20Betreff')
expect(link).toContain('body=Kurzer%20Text.')
})
it('verweigert den Link bei zu langem Text, statt abzuschneiden', () => {
// Der Kern der Beides-Entscheidung: Ein Knopf, der einen halben Brief
// übergibt, wäre schlimmer als keiner.
const lang = { betreff: 'B', text: 'ä'.repeat(MAILTO_HOECHSTLAENGE) }
expect(mailtoLink('a@b.de', lang)).toBeNull()
})
it('verweigert den Link ohne Adresse', () => {
expect(mailtoLink('', fassung)).toBeNull()
expect(mailtoLink(' ', fassung)).toBeNull()
})
it('rechnet die Grenze nach der Kodierung, nicht davor', () => {
// 900 Umlaute sind roh unter der Grenze, kodiert (je 3 Byte -> %C3%A4,
// 6 Zeichen) weit darüber.
const umlaute = { betreff: 'B', text: 'ä'.repeat(900) }
expect(mailtoLink('a@b.de', umlaute)).toBeNull()
})
})
describe('Adressprüfung', () => {
it('erkennt gewöhnliche Adressen', () => {
expect(siehtAusWieEmail('service@allianz.de')).toBe(true)
expect(siehtAusWieEmail('info@stadt-coburg.bayern.de')).toBe(true)
})
it('weist Offensichtliches ab', () => {
expect(siehtAusWieEmail('')).toBe(false)
expect(siehtAusWieEmail(null)).toBe(false)
expect(siehtAusWieEmail('kein-at-zeichen.de')).toBe(false)
expect(siehtAusWieEmail('zwei @ leerzeichen@x.de')).toBe(false)
})
})
describe('.eml-Datei', () => {
it('trägt Empfänger, Betreff und den vollen Text', () => {
const eml = emlErzeugen({
an: 'service@allianz.de',
betreff: 'Testbetreff',
text: 'Zeile eins.\nZeile zwei.',
}).toString('utf8')
expect(eml).toContain('To: service@allianz.de')
expect(eml).toContain('Subject: Testbetreff')
expect(eml).toContain('Zeile eins.')
expect(eml).toContain('Zeile zwei.')
})
it('bittet das Mailprogramm um einen Entwurf', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).toContain('X-Unsent: 1')
})
it('erfindet keinen Absender', () => {
// Ein erfundener From-Kopf würde je nach Programm übernommen - und
// dann ginge die Mail mit falschem Absender hinaus.
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'B', text: 'T' }).toString('utf8')
expect(eml).not.toMatch(/^From:/m)
})
it('kodiert Umlaute im Betreff nach RFC 2047', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Rückkaufswert', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: =?UTF-8?B?')
// Und zurückgerechnet stimmt der Inhalt.
const treffer = eml.match(/Subject: =\?UTF-8\?B\?([A-Za-z0-9+/=]+)\?=/)
expect(Buffer.from(treffer![1]!, 'base64').toString('utf8')).toBe('Rückkaufswert')
})
it('lässt einen ASCII-Betreff lesbar', () => {
const eml = emlErzeugen({ an: 'a@b.de', betreff: 'Anfrage', text: 'T' }).toString('utf8')
expect(eml).toContain('Subject: Anfrage')
})
})
describe('quoted-printable', () => {
it('kodiert Umlaute und lässt ASCII lesbar', () => {
const kodiert = quotedPrintable('Grüße')
expect(kodiert).toContain('Gr')
expect(kodiert).toContain('=C3=BC')
expect(kodiert).toContain('=C3=9F')
})
it('kodiert das Gleichheitszeichen', () => {
expect(quotedPrintable('a=b')).toBe('a=3Db')
})
it('hält die 76-Zeichen-Grenze je Zeile ein', () => {
const kodiert = quotedPrintable('ä'.repeat(200))
for (const zeile of kodiert.split('\r\n')) {
expect(zeile.length).toBeLessThanOrEqual(76)
}
})
it('lässt Kopfzeilen ohne Sonderzeichen unkodiert', () => {
expect(kopfzeileKodieren('Anfrage 2026')).toBe('Anfrage 2026')
})
})