E-Mails als HTML anzeigen; Antworten zitieren Vornachricht (auch KI)

- HTML-Mails werden in einem sandboxed iframe (ohne allow-scripts)
  gerendert statt als Quelltext angezeigt; Höhe an Inhalt angepasst,
  Skripte/Tracking laufen nicht, Layout bleibt isoliert. Reine
  Text-Mails weiterhin als pre-wrap. Gilt für Postfach und Bewerbung.
- Antwortformular ist wie im Mailclient mit der zitierten Vornachricht
  vorbelegt (Attribution + "> "-Zeilen), Cursor darüber.
- KI-Antwort hängt das Zitat unter den generierten Text und erhält den
  bereinigten Klartext (statt HTML) als Eingabe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 23:06:59 +02:00
co-authored by Claude Opus 4.8
parent 7ceff39364
commit b1531663bb
4 changed files with 128 additions and 11 deletions
+58 -2
View File
@@ -101,6 +101,58 @@ if (!fs.existsSync(emailAnhaengeDir)) {
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
}
// --- E-mail display + reply helpers --------------------------------------
// A stored message is rendered as HTML when we have an HTML body (or the plain
// body is actually HTML markup — some senders put HTML in the text/plain part).
// Otherwise it is shown as plain text. HTML is displayed inside a sandboxed
// iframe (see the views), so scripts never run.
function looksLikeHtml(s) {
return /<(?:!doctype|html|body|div|table|p|br|span|a|img|ul|ol|h[1-6])\b|<\/[a-z]/i.test(String(s || ''));
}
function emailDisplayHtml(e) {
if (e.body_html && e.body_html.trim()) return e.body_html;
if (e.body_text && looksLikeHtml(e.body_text)) return e.body_text;
return null;
}
// Wrap raw e-mail HTML in a minimal document for the sandboxed iframe: a white
// background, readable defaults, images constrained to the width and links that
// open in a new tab. No scripts are enabled by the iframe sandbox.
function buildEmailSrcdoc(html) {
return '<!doctype html><html><head><meta charset="utf-8">' +
'<meta name="referrer" content="no-referrer"><base target="_blank">' +
'<style>html,body{margin:0;padding:10px;background:#fff;color:#111;' +
'font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;' +
'word-break:break-word;overflow-wrap:anywhere}' +
'img{max-width:100%!important;height:auto}table{max-width:100%!important}' +
'a{color:#2563eb}*{max-width:100%}</style></head><body>' +
String(html || '') + '</body></html>';
}
// Plain-text version of a message body, used as the source for reply quoting.
function emailPlainText(e) {
const html = emailDisplayHtml(e);
if (html && (!e.body_text || looksLikeHtml(e.body_text))) return mailer.htmlToText(html);
return String(e.body_text || '');
}
// Build a mail-client style quote of a received message: an attribution line
// followed by the original body with every line prefixed by "> ".
function buildReplyQuote(e) {
const d = e.email_date ? new Date(e.email_date) : null;
const when = d && !isNaN(d.getTime()) ? d.toLocaleString('de-DE') : '';
const who = String(e.from_addr || '').trim();
const src = emailPlainText(e).replace(/\r\n/g, '\n').replace(/\s+$/, '');
const quoted = src.split('\n').map((l) => '> ' + l).join('\n');
const attribution = who ? `Am ${when} schrieb ${who}:` : (when ? `Am ${when}:` : '');
return (attribution ? attribution + '\n\n' : '') + quoted;
}
// Attach display fields (and, for received mail, a reply quote) to each row.
function decorateEmails(emails) {
emails.forEach((e) => {
const html = emailDisplayHtml(e);
e.display_srcdoc = html ? buildEmailSrcdoc(html) : null;
if (e.direction !== 'out') e.reply_quote = buildReplyQuote(e);
});
}
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
// and that are sent along with every generated application.
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
@@ -1187,6 +1239,8 @@ initializeDatabase().then(() => {
const m = String(e.from_addr || '').match(/<([^>]+)>/);
e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim();
});
// HTML rendering (sandboxed iframe) + a quote of each received message.
decorateEmails(emails);
// Mark received messages as read now that they are shown.
await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id = ? AND direction = 'in' AND seen = 0", [id]);
}
@@ -1334,12 +1388,13 @@ initializeDatabase().then(() => {
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const draft = await generateEmailReply({
incoming: { from: orig.from_addr, subject: orig.subject, text: orig.body_text },
incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings,
hinweise: String(req.body.hinweise || ''),
});
res.json(draft);
// Include the quoted original so the reply reads like a mail-client thread.
res.json({ ...draft, quote: buildReplyQuote(orig) });
} catch (error) {
console.error('Error drafting AI reply:', error);
res.status(500).json({ error: error.message || 'Serverfehler' });
@@ -1390,6 +1445,7 @@ initializeDatabase().then(() => {
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
decorateEmails(emails);
}
// Applications the user can assign an e-mail to (newest first).
const bewerbungen = await dbAll(