Add Postfach page to manually assign unlinked e-mails
Incoming e-mails that IMAP matching could not link to an application (bewerbung_id IS NULL) were invisible. Add a dedicated page listing them with a dropdown of existing applications to assign each one by hand. A header badge on every page shows the count of unlinked e-mails via a small /api/emails/unassigned-count endpoint. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1181,6 +1181,76 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Postfach: incoming e-mails that landed in the inbox but could not be
|
||||||
|
// matched to an application automatically. The user assigns them by hand.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Page listing all e-mails with no bewerbung_id, plus every application as
|
||||||
|
// assignment target.
|
||||||
|
app.get('/postfach', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const emails = await dbAll(
|
||||||
|
`SELECT * FROM emails WHERE bewerbung_id IS NULL ORDER BY datetime(email_date) DESC, id DESC`
|
||||||
|
);
|
||||||
|
if (emails.length) {
|
||||||
|
const eIds = emails.map((e) => e.id);
|
||||||
|
const atts = await dbAll(
|
||||||
|
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||||
|
eIds
|
||||||
|
);
|
||||||
|
const byEmail = {};
|
||||||
|
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||||
|
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
|
||||||
|
}
|
||||||
|
// Applications the user can assign an e-mail to (newest first).
|
||||||
|
const bewerbungen = await dbAll(
|
||||||
|
`SELECT id, firma, stelle, ort, datum FROM bewerbungen ORDER BY datum DESC, created_at DESC`
|
||||||
|
);
|
||||||
|
res.render('postfach', {
|
||||||
|
emails,
|
||||||
|
bewerbungen,
|
||||||
|
mailConfigured: mailer.isConfigured(),
|
||||||
|
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||||||
|
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||||||
|
hideSettings: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading postfach:', error);
|
||||||
|
res.status(500).send('Serverfehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign an unlinked e-mail to an existing application.
|
||||||
|
app.post('/postfach/:emailId/zuweisen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bewerbungId = Number(req.body.bewerbung_id);
|
||||||
|
if (!bewerbungId) {
|
||||||
|
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Bitte eine Bewerbung auswählen.'));
|
||||||
|
}
|
||||||
|
// Make sure the target application exists and the e-mail is still unlinked.
|
||||||
|
const app = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||||
|
if (!app) {
|
||||||
|
return res.redirect('/postfach?mailerror=' + encodeURIComponent('Ausgewählte Bewerbung existiert nicht.'));
|
||||||
|
}
|
||||||
|
await dbRun('UPDATE emails SET bewerbung_id = ? WHERE id = ? AND bewerbung_id IS NULL', [bewerbungId, req.params.emailId]);
|
||||||
|
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde der Bewerbung zugewiesen.'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assigning e-mail:', error);
|
||||||
|
res.redirect('/postfach?mailerror=' + encodeURIComponent('Zuweisung fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count of unlinked e-mails — drives the header badge on every page.
|
||||||
|
app.get('/api/emails/unassigned-count', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE bewerbung_id IS NULL');
|
||||||
|
res.json({ count: row ? row.count : 0 });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ count: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Postfach (unlinked incoming e-mails) link -->
|
||||||
|
<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"
|
||||||
|
title="E-Mails im Postfach, die noch keiner Bewerbung zugeordnet sind">
|
||||||
|
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Postfach</span>
|
||||||
|
<span id="unassignedBadge" 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>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Vorlagen (base documents) link -->
|
<!-- Vorlagen (base documents) link -->
|
||||||
<a href="/vorlagen"
|
<a href="/vorlagen"
|
||||||
class="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="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"
|
||||||
@@ -49,4 +60,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
||||||
|
(function () {
|
||||||
|
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
var b = document.getElementById('unassignedBadge');
|
||||||
|
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
|
||||||
|
}).catch(function () { /* ignore — badge stays hidden */ });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header', { hideSettings: true }) %>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Postfach — unverknüpfte E-Mails</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
E-Mails aus dem Postfach, die keiner Bewerbung zugeordnet sind. Hier per Hand einer Bewerbung zuweisen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% if (mailConfigured) { %>
|
||||||
|
<form action="/email/fetch" method="POST" class="shrink-0">
|
||||||
|
<input type="hidden" name="back" value="/postfach">
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||||
|
Postfach abrufen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!mailConfigured) { %>
|
||||||
|
<div class="flex items-center gap-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 px-4 py-3">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||||
|
<span class="text-sm text-yellow-800 dark:text-yellow-200">E-Mail ist nicht konfiguriert. Bitte <code>MAIL_HOST</code>, <code>MAIL_USER</code> und <code>MAIL_PASSWORD</code> in der <code>.env</code> setzen.</span>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
|
||||||
|
<% if (mailError) { %>
|
||||||
|
<div class="rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 mb-4 text-sm text-red-800 dark:text-red-200"><%= mailError %></div>
|
||||||
|
<% } %>
|
||||||
|
<% if (mailOk) { %>
|
||||||
|
<div class="rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-3 mb-4 text-sm text-green-800 dark:text-green-200"><%= mailOk %></div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (emails && emails.length) { %>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<% emails.forEach(function(e){ %>
|
||||||
|
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
|
<p class="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.anhaenge && e.anhaenge.length) { %>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<% e.anhaenge.forEach(function(a){ %>
|
||||||
|
<a href="/email-anhaenge/<%= a.id %>/download" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path></svg>
|
||||||
|
<%= a.name || 'Anhang' %>
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Assign to an existing application -->
|
||||||
|
<form action="/postfach/<%= e.id %>/zuweisen" method="POST" class="mt-3 border-t border-gray-200 dark:border-gray-600 pt-3 flex flex-wrap items-end gap-2">
|
||||||
|
<div class="flex-1 min-w-52">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bewerbung zuweisen</label>
|
||||||
|
<select name="bewerbung_id" required
|
||||||
|
class="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">
|
||||||
|
<option value="" disabled selected>— Bewerbung auswählen —</option>
|
||||||
|
<% bewerbungen.forEach(function(b){ %>
|
||||||
|
<option value="<%= b.id %>"><%= b.firma %><% if (b.stelle) { %> · <%= b.stelle %><% } %><% if (b.datum) { %> (<%= new Date(b.datum).toLocaleDateString('de-DE') %>)<% } %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
Zuweisen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center py-10">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Keine unverknüpften E-Mails. Alle Nachrichten im Postfach sind bereits einer Bewerbung zugeordnet.</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const dm = localStorage.getItem('darkMode');
|
||||||
|
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
}
|
||||||
|
const toggle = document.getElementById('darkModeToggle');
|
||||||
|
const sun = document.getElementById('sunIcon');
|
||||||
|
const moon = document.getElementById('moonIcon');
|
||||||
|
function sync() {
|
||||||
|
const d = root.classList.contains('dark');
|
||||||
|
if (sun) sun.classList.toggle('hidden', d);
|
||||||
|
if (moon) moon.classList.toggle('hidden', !d);
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
if (toggle) toggle.addEventListener('click', () => {
|
||||||
|
root.classList.toggle('dark');
|
||||||
|
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
const cy = document.getElementById('currentYear');
|
||||||
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user