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:
@@ -88,7 +88,7 @@ export function dateinameFuerSchreiben(
|
||||
art: SchreibenArt,
|
||||
empfaenger: string | null,
|
||||
datum: Date,
|
||||
endung: 'docx' | 'pdf',
|
||||
endung: 'docx' | 'pdf' | 'eml',
|
||||
): string {
|
||||
const tag = datum.toISOString().slice(0, 10)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user