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:
Florian Wilde
2026-08-23 11:02:00 +02:00
parent a52523fc97
commit b59d11c9b4
15 changed files with 877 additions and 37 deletions
+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({
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' }],
take: 200,
})
+176 -19
View File
@@ -10,6 +10,7 @@ import {
FileDown,
FolderInput,
Info,
Mail,
UserPlus,
} from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
@@ -26,6 +27,7 @@ import {
type SchreibenZustand,
} from '@/server/actions/schreiben'
import type { SchreibenArt } from '@/lib/schreiben/arten'
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
export type KontaktAuswahl = {
id: string
@@ -34,6 +36,7 @@ export type KontaktAuswahl = {
strasse: string | null
plz: string | null
ort: string | null
email: string | null
}
/**
@@ -67,6 +70,10 @@ export function SchreibenEditor({
const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
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 [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
const [kopiert, setKopiert] = React.useState(false)
@@ -94,6 +101,8 @@ export function SchreibenEditor({
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
<input type="hidden" name="vertragId" value={vertragId ?? ''} />
<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)
.join('\n'),
)
setEmpfaengerEmail(kontakt.email ?? '')
}
async function textKopieren() {
@@ -121,6 +131,109 @@ export function SchreibenEditor({
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 (
<div className="space-y-6">
{warnungen.length > 0 && (
@@ -193,6 +306,21 @@ export function SchreibenEditor({
/>
</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() !== '' && (
<form action={kontaktAnlegen}>
<VersteckteFelder />
@@ -263,8 +391,32 @@ export function SchreibenEditor({
<Card>
<CardHeader className="pb-3">
<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>
<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">
<Feld
id="versandAm"
@@ -293,26 +445,31 @@ export function SchreibenEditor({
</Feld>
</div>
{/* Word-Datei: eigene Route, damit der Browser einen echten
Download bekommt. */}
<form action="/schreiben/docx" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als Word-Datei
</Button>
</form>
{versandweg === 'BRIEF' && (
<>
{/* Word-Datei: eigene Route, damit der Browser einen echten
Download bekommt. */}
<form action="/schreiben/docx" method="post" className="inline">
<VersteckteFelder />
<Button type="submit" variant="outline">
<FileDown aria-hidden="true" />
Als Word-Datei
</Button>
</form>
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
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
ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab — dann
lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann ablegen.
</AlertDescription>
</Alert>
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
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 ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab —
dann lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann
ablegen.
</AlertDescription>
</Alert>
</>
)}
{ablageZustand.fehler && (
<Alert variant="fehler">
+1 -1
View File
@@ -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)
+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. */
export const VERSION = '1.4.0'
export const VERSION = '1.5.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+21 -2
View File
@@ -15,6 +15,7 @@ import {
type SchreibenArt,
} from '@/lib/schreiben/arten'
import { briefPdfErzeugen } from '@/lib/schreiben/pdf'
import { emailFassung } from '@/lib/schreiben/email'
/**
* Server Actions rund um erzeugte Schreiben.
@@ -51,6 +52,9 @@ const ablegenSchema = z.object({
wiedervorlageAm: z.string().trim().default(''),
vertragId: 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'),
text: daten.get('text'),
empfaengerName: daten.get('empfaengerName') ?? '',
versandweg: daten.get('versandweg') ?? 'BRIEF',
empfaengerEmail: daten.get('empfaengerEmail') ?? '',
versandAm: daten.get('versandAm') ?? '',
wiedervorlageAm: daten.get('wiedervorlageAm') ?? '',
vertragId: daten.get('vertragId') ?? '',
@@ -110,7 +116,20 @@ export async function schreibenAblegen(
if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' }
// --- 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 ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf')
@@ -118,7 +137,7 @@ export async function schreibenAblegen(
const dokument = await db.dokument.create({
data: {
betreuteId,
titel: werte.betreff.slice(0, 200),
titel: (istEmail ? `E-Mail: ${werte.betreff}` : werte.betreff).slice(0, 200),
art: 'SCHREIBEN',
richtung: 'AUSGANG',
schreibenVom: versandAm,