From 3c553cadf540e22b8a4debedc36e7a2944cb5ead Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 15:20:56 +0200 Subject: [PATCH] Generate an editable norm-compliant cover email per application Alongside the tailored PDFs, the LLM now produces a short, DIN-style Begleit-E-Mail (subject + body) grounded in the same data: same salutation as the cover letter, the target role, a line or two of core motivation, a pointer to the attached documents, and a friendly close. Persisted in new bewerbungen columns (email_betreff, email_anschreiben) and surfaced in an editable subject field + textarea on the detail page, so the applicant can tweak it before sending; a copy button puts subject and body on the clipboard. Values are stored raw and HTML-escaped on render to avoid double-escaping. Co-Authored-By: Claude Opus 4.8 --- lib/documents.js | 35 ++++++++++++++++++++++++++++-- server.js | 31 ++++++++++++++++++++++++--- views/bewerbung.ejs | 52 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index 17dea0b..2473aca 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -82,6 +82,14 @@ const OUTPUT_SCHEMA = { }, required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'], }, + email: { + type: 'object', + properties: { + betreff: { type: 'string' }, + text: { type: 'string' }, + }, + required: ['betreff', 'text'], + }, lebenslauf: { type: 'object', properties: { @@ -116,7 +124,7 @@ const OUTPUT_SCHEMA = { required: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'], }, }, - required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'], + required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'], }; async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) { @@ -174,6 +182,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen ` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` + ` "gruss": "Mit freundlichen Grüßen"\n` + ` },\n` + + ` "email": {\n` + + ` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` + + ` "text": "Kurzer, sofort versendbarer E-Mail-Text: Anrede, 2-3 knappe Absätze, Grußformel, Name - Absätze durch Leerzeile getrennt"\n` + + ` },\n` + ` "lebenslauf": {\n` + ` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` + ` "berufserfahrung": [\n` + @@ -234,6 +246,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` + `fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` + `Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` + + `- email: ein KURZES, normgerechtes Begleit-E-Mail-Anschreiben, mit dem die Bewerbung samt Anhängen ` + + `(Lebenslauf, Anschreiben, ggf. Zeugnisse) per E-Mail versendet wird. Es ist NICHT das vollständige ` + + `Anschreiben - wiederhole dessen Inhalt nicht, fasse dich bewusst knapp (Faustregel: höchstens 6-8 Sätze). ` + + `email.betreff: prägnante Betreffzeile, i. d. R. "Bewerbung als " (eine in Anzeige/Notizen genannte ` + + `Referenz-/Kennnummer anhängen). email.text: vollständiger, sofort versendbarer E-Mail-Text mit exakt dieser ` + + `Struktur - (1) dieselbe Anrede wie im Anschreiben; (2) ein Satz, auf welche Stelle man sich bewirbt (falls ` + + `bekannt: wo die Stelle gefunden wurde); (3) ein bis zwei Sätze zur Kernmotivation/Kernqualifikation; ` + + `(4) ein Satz mit dem Hinweis, dass die vollständigen Bewerbungsunterlagen im Anhang beiliegen; (5) ein ` + + `freundlicher Ausblick auf ein persönliches Gespräch; (6) Grußformel "Mit freundlichen Grüßen"; (7) in der ` + + `letzten Zeile der vollständige Name des Bewerbers. Trenne Anrede, Absätze, Grußformel und Name jeweils durch ` + + `eine Leerzeile (\\n\\n). Höflich, sachlich, in Aktiv-Sätzen, ohne Floskeln und Weichspüler, keine Platzhalter.\n` + `- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` + `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` + `keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` + @@ -486,6 +509,14 @@ function normalizeResult(parsed) { gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), }; })(), + email: (() => { + const em = parsed.email || parsed.email_anschreiben || parsed.mail || parsed.begleitmail || {}; + if (typeof em === 'string') return { betreff: '', text: str(em) }; + return { + betreff: str(pick(em, ['betreff', 'subject', 'titel'])), + text: str(pick(em, ['text', 'body', 'inhalt', 'nachricht', 'mailtext', 'anschreiben'])), + }; + })(), lebenslauf: { profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])), berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen']) @@ -979,7 +1010,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); } - return documents; + return { documents, email: data.email || { betreff: '', text: '' } }; } module.exports = { diff --git a/server.js b/server.js index c236b47..6f2f19c 100644 --- a/server.js +++ b/server.js @@ -254,7 +254,7 @@ async function runGeneration(bewerbungId) { const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); - const documents = await generateApplicationDocuments({ + const { documents, email } = await generateApplicationDocuments({ job: { firma: bewerbung.firma, stelle: bewerbung.stelle, @@ -297,8 +297,9 @@ async function runGeneration(bewerbungId) { } await dbRun( - "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - [bewerbungId] + "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " + + "email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [(email && email.betreff) || '', (email && email.text) || '', bewerbungId] ); console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`); } catch (error) { @@ -340,6 +341,8 @@ function initializeDatabase() { db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN email_betreff TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN email_anschreiben TEXT', () => { db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => { // Chronological status changes, each with an optional comment @@ -435,6 +438,8 @@ function initializeDatabase() { }); }); }); + }); + }); }); }); }); @@ -774,6 +779,26 @@ initializeDatabase().then(() => { } }); + // Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it. + app.post('/bewerbung/:id/email', async (req, res) => { + try { + const { id } = req.params; + const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); + if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); + + // Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how + // the generated e-mail is stored. Sanitising here would double-escape. + await dbRun( + 'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id] + ); + res.redirect('/bewerbung/' + id + '#email'); + } catch (error) { + console.error('Error saving e-mail text:', error); + res.status(500).send('Serverfehler'); + } + }); + // Add a timeline entry (status change with date + comment) app.post('/bewerbung/:id/verlauf', async (req, res) => { try { diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 11d7704..5474bea 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -180,6 +180,45 @@ <% } %> + + <% if ((application.email_anschreiben && application.email_anschreiben.trim()) || genStatus === 'fertig') { %> +
+
+

Begleit-E-Mail

+ +
+

+ Kurzer, normgerechter Text zum Versand der Bewerbung per E-Mail (Unterlagen im Anhang). + Du kannst Betreff und Text vor dem Versand frei anpassen und speichern. +

+
+ + + + +
+ +
+
+
+ <% } %> +
{ + const betreff = (document.getElementById('emailBetreff') || {}).value || ''; + const body = (document.getElementById('emailText') || {}).value || ''; + const label = document.getElementById('emailCopyLabel'); + const payload = (betreff ? 'Betreff: ' + betreff + '\n\n' : '') + body; + try { + await navigator.clipboard.writeText(payload); + if (label) { const t = label.textContent; label.textContent = 'Kopiert!'; setTimeout(() => { label.textContent = t; }, 1500); } + } catch (e) { /* clipboard blocked — ignore */ } + }); + // While documents are being generated, poll for completion and // reload the page once the status changes. const unterlagen = document.getElementById('unterlagen');