Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44b319e7e | ||
|
|
b1531663bb |
@@ -175,5 +175,6 @@ module.exports = {
|
|||||||
fromField,
|
fromField,
|
||||||
fromAddress,
|
fromAddress,
|
||||||
textToHtml,
|
textToHtml,
|
||||||
|
htmlToText,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,58 @@ if (!fs.existsSync(emailAnhaengeDir)) {
|
|||||||
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
|
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
|
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
|
||||||
// and that are sent along with every generated application.
|
// and that are sent along with every generated application.
|
||||||
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
||||||
@@ -1187,6 +1239,8 @@ initializeDatabase().then(() => {
|
|||||||
const m = String(e.from_addr || '').match(/<([^>]+)>/);
|
const m = String(e.from_addr || '').match(/<([^>]+)>/);
|
||||||
e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim();
|
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.
|
// 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]);
|
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 settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||||
|
|
||||||
const draft = await generateEmailReply({
|
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 },
|
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
|
||||||
settings,
|
settings,
|
||||||
hinweise: String(req.body.hinweise || ''),
|
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) {
|
} catch (error) {
|
||||||
console.error('Error drafting AI reply:', error);
|
console.error('Error drafting AI reply:', error);
|
||||||
res.status(500).json({ error: error.message || 'Serverfehler' });
|
res.status(500).json({ error: error.message || 'Serverfehler' });
|
||||||
@@ -1390,6 +1445,11 @@ initializeDatabase().then(() => {
|
|||||||
const byEmail = {};
|
const byEmail = {};
|
||||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||||
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
|
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
|
||||||
|
decorateEmails(emails);
|
||||||
|
// These are read now that they are shown — clears them from the bell.
|
||||||
|
// (The `seen` values above are captured pre-update, so the "Neu" badge
|
||||||
|
// still renders on this view.)
|
||||||
|
await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id IS NULL AND direction = 'in' AND seen = 0");
|
||||||
}
|
}
|
||||||
// Applications the user can assign an e-mail to (newest first).
|
// Applications the user can assign an e-mail to (newest first).
|
||||||
const bewerbungen = await dbAll(
|
const bewerbungen = await dbAll(
|
||||||
@@ -1446,7 +1506,7 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count of unlinked e-mails — drives the header badge on every page.
|
// Count of unlinked e-mails — drives the "Postfach" header badge on every page.
|
||||||
app.get('/api/emails/unassigned-count', async (req, res) => {
|
app.get('/api/emails/unassigned-count', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE bewerbung_id IS NULL');
|
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE bewerbung_id IS NULL');
|
||||||
@@ -1456,6 +1516,54 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unread received e-mails — drives the notification bell on every page. Returns
|
||||||
|
// the total unread count plus the newest few as a ready-to-render list. Replies
|
||||||
|
// auto-assigned to an application carry a link to that application; unassigned
|
||||||
|
// mail links to the Postfach.
|
||||||
|
app.get('/api/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cntRow = await dbGet(
|
||||||
|
"SELECT COUNT(*) AS count FROM emails WHERE direction = 'in' AND seen = 0"
|
||||||
|
);
|
||||||
|
const rows = await dbAll(
|
||||||
|
`SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id,
|
||||||
|
b.firma, b.stelle
|
||||||
|
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
|
||||||
|
WHERE e.direction = 'in' AND e.seen = 0
|
||||||
|
ORDER BY datetime(e.email_date) DESC, e.id DESC
|
||||||
|
LIMIT 30`
|
||||||
|
);
|
||||||
|
const items = rows.map((e) => {
|
||||||
|
const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim()
|
||||||
|
|| String(e.from_addr || '').trim();
|
||||||
|
const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140);
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
from: fromName || '(unbekannt)',
|
||||||
|
subject: e.subject || '(kein Betreff)',
|
||||||
|
snippet,
|
||||||
|
date: e.email_date || null,
|
||||||
|
bewerbung_id: e.bewerbung_id || null,
|
||||||
|
kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '',
|
||||||
|
url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ count: cntRow ? cntRow.count : 0, items });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ count: 0, items: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark every received e-mail as read (clears the notification bell).
|
||||||
|
app.post('/api/emails/mark-all-read', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await dbRun("UPDATE emails SET seen = 1 WHERE direction = 'in' AND seen = 0");
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add a timeline entry (status change with date + comment)
|
// Add a timeline entry (status change with date + comment)
|
||||||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+40
-4
@@ -336,7 +336,13 @@
|
|||||||
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
|
<% if (e.display_srcdoc) { %>
|
||||||
|
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
||||||
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer"
|
||||||
|
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
||||||
|
<% } else { %>
|
||||||
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= e.body_text || '' %></p>
|
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= e.body_text || '' %></p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% if (e.anhaenge && e.anhaenge.length) { %>
|
<% if (e.anhaenge && e.anhaenge.length) { %>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@@ -369,8 +375,8 @@
|
|||||||
<span class="ai-reply-label">KI-Antwort generieren</span>
|
<span class="ai-reply-label">KI-Antwort generieren</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="body" rows="8" required
|
<textarea name="body" rows="12" required
|
||||||
class="reply-body w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"></textarea>
|
class="reply-body w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"><%= e.reply_quote ? '\n\n' + e.reply_quote : '' %></textarea>
|
||||||
<div class="flex justify-end mt-2">
|
<div class="flex justify-end mt-2">
|
||||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">Antwort senden</button>
|
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">Antwort senden</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,11 +694,36 @@
|
|||||||
} catch (e) { /* clipboard blocked — ignore */ }
|
} 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.
|
// Toggle inline reply forms under received e-mails.
|
||||||
document.querySelectorAll('.reply-toggle').forEach((btn) => {
|
document.querySelectorAll('.reply-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const form = document.getElementById(btn.dataset.target);
|
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');
|
if (!res.ok) throw new Error(data.error || 'Fehler');
|
||||||
const body = form.querySelector('.reply-body');
|
const body = form.querySelector('.reply-body');
|
||||||
const subject = form.querySelector('.reply-subject');
|
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;
|
if (subject && data.betreff) subject.value = data.betreff;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('KI-Antwort fehlgeschlagen: ' + e.message);
|
alert('KI-Antwort fehlgeschlagen: ' + e.message);
|
||||||
|
|||||||
@@ -9,6 +9,26 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
||||||
|
<div class="relative" id="notifWrap">
|
||||||
|
<button id="notifBtn" type="button"
|
||||||
|
class="relative flex items-center p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
||||||
|
aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="notifBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
||||||
|
</button>
|
||||||
|
<div id="notifPanel" class="hidden absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/10 dark:ring-white/10 z-50 text-left">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
|
||||||
|
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
|
||||||
|
<button id="notifMarkAll" type="button" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline">Alle gelesen</button>
|
||||||
|
</div>
|
||||||
|
<ul id="notifList" class="divide-y divide-gray-100 dark:divide-gray-700"></ul>
|
||||||
|
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Postfach (unlinked incoming e-mails) link -->
|
<!-- Postfach (unlinked incoming e-mails) link -->
|
||||||
<a href="/postfach"
|
<a href="/postfach"
|
||||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||||
@@ -83,6 +103,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// Notification bell: unread received e-mails (new replies + orphan mail).
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('notifBtn');
|
||||||
|
var panel = document.getElementById('notifPanel');
|
||||||
|
var badge = document.getElementById('notifBadge');
|
||||||
|
var list = document.getElementById('notifList');
|
||||||
|
var empty = document.getElementById('notifEmpty');
|
||||||
|
var markAll = document.getElementById('notifMarkAll');
|
||||||
|
if (!btn) return;
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
try {
|
||||||
|
var dt = new Date(d), diff = (Date.now() - dt.getTime()) / 1000;
|
||||||
|
if (diff < 60) return 'gerade eben';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + ' Min.';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + ' Std.';
|
||||||
|
return dt.toLocaleDateString('de-DE');
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function render(data) {
|
||||||
|
var items = (data && data.items) || [], count = (data && data.count) || 0;
|
||||||
|
if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('hidden'); }
|
||||||
|
else { badge.classList.add('hidden'); }
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!items.length) { empty.classList.remove('hidden'); markAll.classList.add('hidden'); return; }
|
||||||
|
empty.classList.add('hidden'); markAll.classList.remove('hidden');
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = it.url;
|
||||||
|
a.className = 'block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors';
|
||||||
|
a.innerHTML =
|
||||||
|
'<div class="flex items-center justify-between gap-2">'
|
||||||
|
+ '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(it.from) + '</span>'
|
||||||
|
+ '<span class="text-[11px] text-gray-400 shrink-0">' + esc(fmtDate(it.date)) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(it.subject) + '</div>'
|
||||||
|
+ (it.snippet ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' + esc(it.snippet) + '</div>' : '')
|
||||||
|
+ (it.kontext ? '<div class="text-[11px] text-blue-600 dark:text-blue-400 truncate mt-0.5">' + esc(it.kontext) + '</div>' : '');
|
||||||
|
li.appendChild(a); list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function load() {
|
||||||
|
fetch('/api/notifications', { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
if (!panel.classList.contains('hidden')) load();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!panel.classList.contains('hidden') && !panel.contains(e.target) && !btn.contains(e.target)) {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
markAll.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
fetch('/api/emails/mark-all-read', { method: 'POST' })
|
||||||
|
.then(function () { render({ count: 0, items: [] }); }).catch(function () { /* ignore */ });
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
setInterval(load, 60000);
|
||||||
|
})();
|
||||||
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
||||||
(function () {
|
(function () {
|
||||||
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
|||||||
@@ -60,11 +60,17 @@
|
|||||||
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
|
<% if (e.display_srcdoc) { %>
|
||||||
|
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
||||||
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer"
|
||||||
|
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
||||||
|
<% } else { %>
|
||||||
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
||||||
<% if (e.body_text && e.body_text.length > 600) { %>
|
<% if (e.body_text && e.body_text.length > 600) { %>
|
||||||
<button type="button" class="email-toggle text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline mt-1"
|
<button type="button" class="email-toggle text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline mt-1"
|
||||||
data-expanded="false">Vollständig anzeigen</button>
|
data-expanded="false">Vollständig anzeigen</button>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<% if (e.anhaenge && e.anhaenge.length) { %>
|
<% if (e.anhaenge && e.anhaenge.length) { %>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@@ -146,6 +152,24 @@
|
|||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
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.
|
// Expand/collapse the full e-mail body for each card.
|
||||||
document.querySelectorAll('.email-toggle').forEach(function (btn) {
|
document.querySelectorAll('.email-toggle').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user