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:
@@ -262,6 +262,142 @@
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<!-- E-Mail-Korrespondenz -->
|
||||
<section id="korrespondenz" 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">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">E-Mail-Korrespondenz</h2>
|
||||
<% if (mailConfigured) { %>
|
||||
<form action="/email/fetch" method="POST" class="shrink-0">
|
||||
<input type="hidden" name="back" value="/bewerbung/<%= application.id %>#korrespondenz">
|
||||
<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>
|
||||
<% } %>
|
||||
|
||||
<!-- Conversation thread -->
|
||||
<% if (emails && emails.length) { %>
|
||||
<ul class="space-y-3 mb-6">
|
||||
<% emails.forEach(function(e){ var out = e.direction === 'out'; %>
|
||||
<li class="rounded-lg border <%= out ? 'border-blue-200 dark:border-blue-800 bg-blue-50/60 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %> px-4 py-3">
|
||||
<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 <%= out ? 'bg-blue-600 text-white' : 'bg-gray-600 text-white' %>"><%= out ? 'Gesendet' : 'Empfangen' %></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">
|
||||
<%= out ? ('An: ' + (e.to_addr || '')) : ('Von: ' + (e.from_addr || '')) %>
|
||||
</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"><%= 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>
|
||||
<% } %>
|
||||
|
||||
<% if (!out) { %>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="reply-toggle text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline" data-target="reply-<%= e.id %>">Antworten</button>
|
||||
</div>
|
||||
<form action="/bewerbung/<%= application.id %>/email/send" method="POST" id="reply-<%= e.id %>" class="hidden mt-3 border-t border-gray-200 dark:border-gray-600 pt-3">
|
||||
<input type="hidden" name="reply_to" value="<%= e.id %>">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">An</label>
|
||||
<input type="email" name="to" required value="<%= e.from_addr_clean || '' %>"
|
||||
class="w-full px-3 py-2 mb-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">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||
<input type="text" name="subject" value="Re: <%= e.subject || '' %>"
|
||||
class="reply-subject w-full px-3 py-2 mb-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">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label>
|
||||
<button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
<span class="ai-reply-label">KI-Antwort generieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="body" rows="8" 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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<% } else { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Noch keine E-Mails. Sende die Bewerbung unten oder rufe das Postfach ab.</p>
|
||||
<% } %>
|
||||
|
||||
<!-- Compose / send the application -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-white mb-1">Bewerbung per E-Mail senden</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Versand über <code><%= mailFrom %></code>. Empfängeradresse eingeben, Betreff und Text prüfen,
|
||||
Anhänge auswählen und senden.
|
||||
</p>
|
||||
<form action="/bewerbung/<%= application.id %>/email/send" method="POST">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Empfänger (E-Mail)</label>
|
||||
<input type="email" name="to" required value="<%= application.email_empfaenger || '' %>"
|
||||
placeholder="personal@unternehmen.de"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||
<input type="text" name="subject" value="<%= application.email_betreff || ('Bewerbung als ' + (application.stelle || '')) %>"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nachricht</label>
|
||||
<textarea name="body" rows="10"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"><%= application.email_anschreiben || '' %></textarea>
|
||||
|
||||
<% if (anhaenge && anhaenge.length) { %>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Anhänge</p>
|
||||
<div class="space-y-1 mb-3">
|
||||
<% anhaenge.forEach(function(a){ %>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input type="checkbox" name="anhang" value="<%= a.id %>" checked
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
|
||||
<%= a.name || a.dateiname %> <span class="text-xs text-gray-400">(<%= a.dateiname %>)</span>
|
||||
</label>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p class="text-xs text-gray-400 mb-3">Keine generierten Anhänge vorhanden - erst Unterlagen generieren.</p>
|
||||
<% } %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-green-600 hover:bg-green-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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
|
||||
Bewerbung senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<!-- Status timeline -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
|
||||
@@ -397,6 +533,44 @@
|
||||
} catch (e) { /* clipboard blocked — ignore */ }
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
// Generate an AI draft reply and drop it into the reply form.
|
||||
document.querySelectorAll('.ai-reply-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const form = document.getElementById(btn.dataset.target);
|
||||
if (!form) return;
|
||||
const label = btn.querySelector('.ai-reply-label');
|
||||
const prev = label ? label.textContent : '';
|
||||
if (label) label.textContent = 'Generiere …';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_id: btn.dataset.emailId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
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 (subject && data.betreff) subject.value = data.betreff;
|
||||
} catch (e) {
|
||||
alert('KI-Antwort fehlgeschlagen: ' + e.message);
|
||||
} finally {
|
||||
if (label) label.textContent = prev;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// While documents are being generated, poll for completion and
|
||||
// reload the page once the status changes.
|
||||
const unterlagen = document.getElementById('unterlagen');
|
||||
|
||||
Reference in New Issue
Block a user