Add e-mail system: send applications, receive & reply to responses

Send the complete application (body + generated attachments) to a
user-entered address via authenticated SMTP submission through the
account's own server, so it applies DKIM and uses its reputable IP/PTR —
required for deliverability here (domain publishes SPF -all, DMARC
p=reject). From/Return-Path stay aligned on the sending domain.

Reply e-mails are polled over IMAP, parsed, stored and matched to the
right application (via In-Reply-To/References, then sender address);
their attachments are saved and downloadable. The detail page gains a
correspondence thread with compose, threaded reply, and an AI-drafted
reply the user can edit before sending.

New: lib/mailer.js (nodemailer + imapflow + mailparser), generateEmailReply
in lib/documents.js, emails/email_anhaenge/app_state tables, background
poller + manual fetch. Credentials come from MAIL_* env vars (.env, not
committed / not in the image).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:59:32 +02:00
co-authored by Claude Opus 4.8
parent 3c553cadf5
commit 0b44f61410
7 changed files with 1289 additions and 4 deletions
+93
View File
@@ -1013,8 +1013,101 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
return { documents, email: data.email || { betreff: '', text: '' } };
}
// ===========================================================================
// AI reply drafting — draft a professional German reply to an incoming email
// ===========================================================================
// Small shared Ollama JSON call (used by the reply drafter).
async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
const apiKey = process.env.OLLAMA_API_KEY;
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
let res;
try {
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: OLLAMA_MODEL,
stream: false,
format: schema,
options: { temperature },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`);
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`);
}
const data = await res.json();
const textOut = ((data && data.message && data.message.content) || '').trim();
if (!textOut) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).');
const cleaned = textOut.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
try { return JSON.parse(cleaned); } catch (e) {
const m = cleaned.match(/\{[\s\S]*\}/);
if (m) { try { return JSON.parse(m[0]); } catch (_) { /* ignore */ } }
}
throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.');
}
const REPLY_SCHEMA = {
type: 'object',
properties: { betreff: { type: 'string' }, text: { type: 'string' } },
required: ['betreff', 'text'],
};
// Draft a reply to a recruiter/company e-mail in the context of an application.
// `incoming` = { from, subject, text }; `job` = { firma, stelle };
// `settings` = { name, ... }. `hinweise` = optional free-text steering.
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '' }) {
const name = (settings && settings.name) || 'der Bewerber';
const system =
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du antwortest konkret auf die eingegangene Nachricht. Wichtig: Du erfindest KEINE ' +
'Fakten (keine erfundenen Termine, Zahlen, Zusagen). Wenn eine konkrete Angabe nötig ist, ' +
'die du nicht kennst (z. B. ein genauer Terminvorschlag), setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' +
'knapp und verbindlich, ohne Floskeln.';
const user =
`# Kontext der Bewerbung\n` +
`Unternehmen: ${job.firma || '-'}\n` +
`Stelle: ${job.stelle || '-'}\n` +
`Bewerber: ${name}\n\n` +
`# Eingegangene E-Mail\n` +
`Von: ${incoming.from || '-'}\n` +
`Betreff: ${incoming.subject || '-'}\n\n` +
`${incoming.text || ''}\n\n` +
(hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
`# Aufgabe\n` +
`Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` +
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` +
`2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
`Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` +
`"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` +
`Verwende ausschließlich den einfachen Bindestrich "-" (niemals oder —). ` +
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 });
return {
betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || ('Re: ' + (incoming.subject || '')),
text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])),
};
}
module.exports = {
generateApplicationDocuments,
generateEmailReply,
generateTailoredTexts,
renderLebenslaufPdf,
renderAnschreibenPdf,