Schreiben als E-Mail uebernehmen
Im Schreiben-Editor gibt es jetzt den Umschalter Brief/E-Mail. Die E-Mail-Fassung wird aus dem - womoeglich bearbeiteten - Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, aber mit dem Bestellungsabsatz. Betreff und Text einzeln kopierbar. Die offene Entscheidung aus dem Issue (mailto oder .eml) ist nach Rueckfrage mit BEIDES beantwortet: - mailto fuer kurze Texte. Der Knopf verschwindet, sobald der kodierte Link 1800 Zeichen uebersteigt - mailto uebergibt den Text in der Adresszeile, deren Laenge nirgends zugesichert ist, und ein halbierter Brief faellt erst beim Empfaenger auf. Die Grenze wird NACH der URL-Kodierung gerechnet; 900 Umlaute sind roh harmlos und kodiert weit drueber. - .eml immer: RFC 5322 mit X-Unsent: 1, quoted-printable fuer den Rumpf, RFC-2047-Kodierung fuer Umlaute im Betreff. Bewusst ohne From-Kopf - ein erfundener Absender wuerde je nach Programm uebernommen. Beide Knoepfe erscheinen nur mit E-Mail-Adresse (aus dem Kontakt uebernommen oder von Hand). Ohne Adresse bleibt das Kopieren. Die Ablage kennt den Unterschied: Eine E-Mail heisst in der Akte "E-Mail: ...", und abgelegt wird die E-Mail-Fassung samt Empfaengeradresse im Kopf der PDF - in der Akte soll stehen, was tatsaechlich hinausging. Sicherung in emailFassung(): Findet sich keine Anrede (frei umgebauter Text), wird NICHTS entfernt. Lieber ein Briefkopf zu viel in der Mail als stillschweigend verlorener Inhalt. Geprueft: 20 Einheitstests, vier End-to-End-Tests auf beiden Geraeteprofilen. 590 Unit-Tests, 81 e2e, null Typfehler. Refs #5
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user