diff --git a/lib/mailer.js b/lib/mailer.js index d5e12df..2838781 100644 --- a/lib/mailer.js +++ b/lib/mailer.js @@ -175,5 +175,6 @@ module.exports = { fromField, fromAddress, textToHtml, + htmlToText, config: cfg, }; diff --git a/server.js b/server.js index eba322a..d8114df 100644 --- a/server.js +++ b/server.js @@ -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 '' + + '' + + '' + + String(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( diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 29aef44..a69e5e3 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -336,7 +336,13 @@ <%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %>

<%= e.subject || '(kein Betreff)' %>

-

<%= e.body_text || '' %>

+ <% if (e.display_srcdoc) { %> + + <% } else { %> +

<%= e.body_text || '' %>

+ <% } %> <% if (e.anhaenge && e.anhaenge.length) { %>
@@ -369,8 +375,8 @@ KI-Antwort generieren
- +
@@ -688,11 +694,36 @@ } catch (e) { /* clipboard blocked — ignore */ } }); + // Size a sandboxed e-mail iframe to its content height. The sandbox + // enables allow-same-origin (but not allow-scripts), so scripts in the + // mail never run, yet we can still measure the rendered document. + function fitFrame(f) { + try { + const doc = f.contentDocument || (f.contentWindow && f.contentWindow.document); + if (!doc || !doc.body) return; + const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight); + if (h > 0) f.style.height = (h + 6) + 'px'; + } catch (e) { /* cross-origin (shouldn't happen) — leave default height */ } + } + document.querySelectorAll('.email-html-frame').forEach((f) => { + f.addEventListener('load', () => fitFrame(f)); + fitFrame(f); + // Late reflow once embedded images have loaded. + setTimeout(() => fitFrame(f), 400); + setTimeout(() => fitFrame(f), 1500); + }); + // Toggle inline reply forms under received e-mails. document.querySelectorAll('.reply-toggle').forEach((btn) => { btn.addEventListener('click', () => { const form = document.getElementById(btn.dataset.target); - if (form) form.classList.toggle('hidden'); + if (!form) return; + form.classList.toggle('hidden'); + // On open, put the cursor above the quoted text. + if (!form.classList.contains('hidden')) { + const ta = form.querySelector('.reply-body'); + if (ta) { ta.focus(); try { ta.setSelectionRange(0, 0); } catch (e) {} ta.scrollTop = 0; } + } }); }); @@ -715,7 +746,12 @@ if (!res.ok) throw new Error(data.error || 'Fehler'); const body = form.querySelector('.reply-body'); const subject = form.querySelector('.reply-subject'); - if (body && data.text) body.value = data.text; + if (body && data.text) { + // Generated reply on top, quoted original beneath — like a mail client. + body.value = data.text + (data.quote ? '\n\n' + data.quote : ''); + body.focus(); + try { body.setSelectionRange(data.text.length, data.text.length); } catch (e) {} + } if (subject && data.betreff) subject.value = data.betreff; } catch (e) { alert('KI-Antwort fehlgeschlagen: ' + e.message); diff --git a/views/postfach.ejs b/views/postfach.ejs index efac268..c56412b 100644 --- a/views/postfach.ejs +++ b/views/postfach.ejs @@ -60,10 +60,16 @@ <%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %>

<%= e.subject || '(kein Betreff)' %>

-

<%= e.body_text || '' %>

- <% if (e.body_text && e.body_text.length > 600) { %> - + <% if (e.display_srcdoc) { %> + + <% } else { %> +

<%= e.body_text || '' %>

+ <% if (e.body_text && e.body_text.length > 600) { %> + + <% } %> <% } %> <% if (e.anhaenge && e.anhaenge.length) { %> @@ -146,6 +152,24 @@ const cy = document.getElementById('currentYear'); if (cy) cy.textContent = new Date().getFullYear(); + // Size each sandboxed e-mail iframe to its rendered content. The + // sandbox omits allow-scripts, so mail scripts never run; allow-same- + // origin lets us measure the document height. + function fitFrame(f) { + try { + var doc = f.contentDocument || (f.contentWindow && f.contentWindow.document); + if (!doc || !doc.body) return; + var h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight); + if (h > 0) f.style.height = (h + 6) + 'px'; + } catch (e) { /* leave default height */ } + } + document.querySelectorAll('.email-html-frame').forEach(function (f) { + f.addEventListener('load', function () { fitFrame(f); }); + fitFrame(f); + setTimeout(function () { fitFrame(f); }, 400); + setTimeout(function () { fitFrame(f); }, 1500); + }); + // Expand/collapse the full e-mail body for each card. document.querySelectorAll('.email-toggle').forEach(function (btn) { btn.addEventListener('click', function () {