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:
@@ -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,
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// E-Mail transport: authenticated SMTP submission (send) + IMAP (receive).
|
||||
//
|
||||
// Deliverability by design: all mail is sent through the account's own
|
||||
// submission server (MAIL_HOST) with SMTP AUTH, so the server applies its
|
||||
// DKIM signature and uses its reputable IP/PTR. Combined with a From address
|
||||
// on the same domain, this satisfies SPF and DKIM alignment — required here
|
||||
// because the domain publishes SPF "-all" and DMARC "p=reject". Sending mail
|
||||
// directly to the recipient MX (bypassing the submission server) would fail
|
||||
// both and land in spam / get rejected, so we never do that.
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
const { ImapFlow } = require('imapflow');
|
||||
const { simpleParser } = require('mailparser');
|
||||
|
||||
function cfg() {
|
||||
const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587);
|
||||
return {
|
||||
host: process.env.MAIL_HOST || '',
|
||||
smtpPort,
|
||||
imapPort: Number(process.env.MAIL_IMAP_PORT || 993),
|
||||
user: process.env.MAIL_USER || '',
|
||||
pass: process.env.MAIL_PASSWORD || '',
|
||||
fromName: process.env.MAIL_FROM_NAME || '',
|
||||
fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '',
|
||||
mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX',
|
||||
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
||||
smtpSecure: smtpPort === 465,
|
||||
};
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
const c = cfg();
|
||||
return Boolean(c.host && c.user && c.pass);
|
||||
}
|
||||
|
||||
// "Anzeigename <adresse>" for the From header.
|
||||
function fromField() {
|
||||
const c = cfg();
|
||||
return c.fromName ? `${c.fromName} <${c.fromAddr}>` : c.fromAddr;
|
||||
}
|
||||
function fromAddress() {
|
||||
return cfg().fromAddr;
|
||||
}
|
||||
|
||||
function buildTransport() {
|
||||
const c = cfg();
|
||||
return nodemailer.createTransport({
|
||||
host: c.host,
|
||||
port: c.smtpPort,
|
||||
secure: c.smtpSecure,
|
||||
requireTLS: !c.smtpSecure, // enforce STARTTLS on 587
|
||||
auth: { user: c.user, pass: c.pass },
|
||||
// Present a sane FQDN in EHLO (matches the sending domain).
|
||||
name: (c.fromAddr.split('@')[1] || c.host || undefined),
|
||||
});
|
||||
}
|
||||
|
||||
// Verify SMTP connectivity + credentials (used by a health check / startup log).
|
||||
async function verify() {
|
||||
const t = buildTransport();
|
||||
try { await t.verify(); return true; } finally { t.close(); }
|
||||
}
|
||||
|
||||
// Minimal HTML rendering of a plain-text body: escape, then turn newlines into
|
||||
// <br>. Sending multipart/alternative (text + html) reads as normal personal
|
||||
// mail and avoids the "text-only, no html" heuristic some filters apply.
|
||||
function textToHtml(text) {
|
||||
const esc = String(text || '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;white-space:pre-wrap">${esc}</div>`;
|
||||
}
|
||||
|
||||
// Send one message via authenticated submission. Returns nodemailer info
|
||||
// (includes messageId). `attachments` is an array of {filename, path|content, contentType}.
|
||||
async function sendMail({ to, subject, text, html, attachments, inReplyTo, references, cc, bcc }) {
|
||||
const t = buildTransport();
|
||||
try {
|
||||
const headers = {};
|
||||
if (inReplyTo) headers['In-Reply-To'] = inReplyTo;
|
||||
if (references) headers['References'] = references;
|
||||
return await t.sendMail({
|
||||
from: fromField(),
|
||||
to,
|
||||
cc: cc || undefined,
|
||||
bcc: bcc || undefined,
|
||||
subject: subject || '',
|
||||
text: text || '',
|
||||
html: html || textToHtml(text),
|
||||
attachments: attachments || [],
|
||||
headers,
|
||||
// Message-ID auto-generated on the From domain → clean threading + DMARC.
|
||||
});
|
||||
} finally {
|
||||
t.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch messages from the mailbox with a UID strictly greater than `sinceUid`.
|
||||
// Returns { messages: [...parsed...], maxUid }. Each parsed message exposes the
|
||||
// UID plus the fields we persist. Guards the classic IMAP "N:*" quirk (a range
|
||||
// whose start exceeds the highest UID still returns the last message) by
|
||||
// filtering uid > sinceUid in code.
|
||||
async function fetchSince(sinceUid = 0) {
|
||||
const c = cfg();
|
||||
const client = new ImapFlow({
|
||||
host: c.host, port: c.imapPort, secure: true,
|
||||
auth: { user: c.user, pass: c.pass },
|
||||
logger: false,
|
||||
});
|
||||
const messages = [];
|
||||
let maxUid = sinceUid;
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock(c.mailbox);
|
||||
try {
|
||||
const box = client.mailbox;
|
||||
if (box && box.exists > 0) {
|
||||
const from = Math.max(1, Number(sinceUid) + 1);
|
||||
for await (const msg of client.fetch(`${from}:*`, { uid: true, source: true, flags: true }, { uid: true })) {
|
||||
if (msg.uid <= sinceUid) continue; // dodge the N:* quirk
|
||||
if (msg.uid > maxUid) maxUid = msg.uid;
|
||||
let parsed = null;
|
||||
try { parsed = await simpleParser(msg.source); } catch (e) { parsed = null; }
|
||||
if (!parsed) continue;
|
||||
const fromV = (parsed.from && parsed.from.value && parsed.from.value[0]) || {};
|
||||
const toText = parsed.to && parsed.to.text ? parsed.to.text : '';
|
||||
messages.push({
|
||||
uid: msg.uid,
|
||||
messageId: stripBrackets(parsed.messageId),
|
||||
inReplyTo: stripBrackets(parsed.inReplyTo),
|
||||
references: Array.isArray(parsed.references)
|
||||
? parsed.references.map(stripBrackets).join(' ')
|
||||
: stripBrackets(parsed.references),
|
||||
fromAddr: (fromV.address || '').toLowerCase(),
|
||||
fromName: fromV.name || '',
|
||||
toAddr: toText,
|
||||
subject: parsed.subject || '',
|
||||
text: parsed.text || (parsed.html ? htmlToText(parsed.html) : ''),
|
||||
html: parsed.html || '',
|
||||
date: parsed.date ? new Date(parsed.date) : new Date(),
|
||||
seen: !!(msg.flags && msg.flags.has && msg.flags.has('\\Seen')),
|
||||
attachments: (parsed.attachments || [])
|
||||
.filter((a) => a.content && a.filename)
|
||||
.map((a) => ({ filename: a.filename, contentType: a.contentType || 'application/octet-stream', content: a.content })),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
return { messages, maxUid };
|
||||
}
|
||||
|
||||
function stripBrackets(v) {
|
||||
if (!v) return '';
|
||||
return String(v).replace(/[<>]/g, '').trim();
|
||||
}
|
||||
|
||||
// Very small HTML→text fallback for html-only mails.
|
||||
function htmlToText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||
.replace(/<\s*\/p\s*>/gi, '\n\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
verify,
|
||||
sendMail,
|
||||
fetchSince,
|
||||
fromField,
|
||||
fromAddress,
|
||||
textToHtml,
|
||||
config: cfg,
|
||||
};
|
||||
Reference in New Issue
Block a user