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:
2026-07-03 15:59:32 +02:00
co-authored by Claude Opus 4.8
parent 3c553cadf5
commit 0b44f61410
7 changed files with 1289 additions and 4 deletions
+294 -1
View File
@@ -27,7 +27,8 @@ const multer = require('multer');
}
})();
const { generateApplicationDocuments } = require('./lib/documents');
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const mailer = require('./lib/mailer');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -76,6 +77,12 @@ if (!fs.existsSync(anhaengeDir)) {
fs.mkdirSync(anhaengeDir, { recursive: true });
}
// Directory for attachments received via IMAP (reply e-mails).
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
if (!fs.existsSync(emailAnhaengeDir)) {
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
}
// 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');
@@ -221,6 +228,95 @@ function dbRun(sql, params = []) {
});
}
// ---------------------------------------------------------------------------
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
// ---------------------------------------------------------------------------
async function getState(key) {
const row = await dbGet('SELECT value FROM app_state WHERE key = ?', [key]);
return row ? row.value : null;
}
async function setState(key, value) {
await dbRun(
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, String(value)]
);
}
// Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) {
const refs = []
.concat((msg.inReplyTo || '').split(/\s+/))
.concat((msg.references || '').split(/\s+/))
.map((r) => r.replace(/[<>]/g, '').trim())
.filter(Boolean);
for (const mid of refs) {
const row = await dbGet(
"SELECT bewerbung_id FROM emails WHERE direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
[mid]
);
if (row && row.bewerbung_id) return row.bewerbung_id;
}
if (msg.fromAddr) {
const row = await dbGet(
"SELECT bewerbung_id FROM emails WHERE direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
['%' + msg.fromAddr.toLowerCase() + '%']
);
if (row && row.bewerbung_id) return row.bewerbung_id;
}
return null;
}
let polling = false;
// Fetch new mail from the IMAP inbox, persist unseen messages, link them to the
// matching application and save their attachments. Safe to call concurrently
// (guarded) — used both by the interval poller and the manual "fetch" button.
async function pollInbox() {
if (!mailer.isConfigured() || polling) return { fetched: 0 };
polling = true;
try {
const lastUid = Number(await getState('mail_last_uid')) || 0;
const { messages, maxUid } = await mailer.fetchSince(lastUid);
let stored = 0;
for (const m of messages) {
// Skip if we already have this message (id or uid) — idempotent.
if (m.messageId) {
const dup = await dbGet('SELECT id FROM emails WHERE message_id = ?', [m.messageId]);
if (dup) continue;
}
const bewId = await matchBewerbung(m);
const result = await dbRun(
`INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references,
from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date)
VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
[bewId, m.messageId || null, m.inReplyTo || null, m.references || null,
m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '',
m.subject || '', m.text || '', m.html || '', m.uid,
(m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())]
);
// Persist attachments to disk + link rows.
for (const att of (m.attachments || [])) {
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
const storedName = `${result.lastID}_${Date.now()}_${safe}`;
try {
fs.writeFileSync(path.join(emailAnhaengeDir, storedName), att.content);
await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)',
[result.lastID, att.filename, att.contentType, storedName]);
} catch (e) { /* ignore a single bad attachment */ }
}
stored++;
}
if (maxUid > lastUid) await setState('mail_last_uid', maxUid);
return { fetched: stored };
} catch (err) {
console.error('IMAP-Abruf fehlgeschlagen:', err.message);
return { fetched: 0, error: err.message };
} finally {
polling = false;
}
}
// Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) {
const latest = await dbGet(
@@ -399,6 +495,50 @@ function initializeDatabase() {
`, (err) => {
if (err) return reject(err);
// E-Mail correspondence (sent + received), linked to an application.
// Serialized mode guarantees these run after the tables above exist.
db.run(`
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bewerbung_id INTEGER,
direction TEXT NOT NULL,
message_id TEXT,
in_reply_to TEXT,
email_references TEXT,
from_addr TEXT,
to_addr TEXT,
subject TEXT,
body_text TEXT,
body_html TEXT,
attachments_json TEXT,
imap_uid INTEGER,
seen INTEGER DEFAULT 1,
email_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS email_anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email_id INTEGER NOT NULL,
name TEXT,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
)
`);
// Small key/value store (e.g. last processed IMAP UID).
db.run(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT
)
`);
// Remember the last recipient address per application (prefill).
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -746,10 +886,38 @@ initializeDatabase().then(() => {
);
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
// E-Mail correspondence (sent + received), oldest first, with attachments.
const emails = await dbAll(
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id]
);
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] || [];
// Bare address for prefilling a reply's "To" (from "Name <addr>").
const m = String(e.from_addr || '').match(/<([^>]+)>/);
e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim();
});
// 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]);
}
res.render('bewerbung', {
application,
verlauf,
anhaenge,
emails,
mailConfigured: mailer.isConfigured(),
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
mailOk: req.query.mailok ? String(req.query.mailok) : '',
basisCount: basisCountRow ? basisCountRow.count : 0,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
@@ -799,6 +967,119 @@ initializeDatabase().then(() => {
}
});
// Send an e-mail for an application (initial application or a reply). Sends
// via authenticated submission, records it as an outgoing message and links
// the chosen generated attachments.
app.post('/bewerbung/:id/email/send', async (req, res) => {
const { id } = req.params;
const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz');
try {
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
if (!mailer.isConfigured()) {
return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (.env).') + '#korrespondenz'));
}
const to = String(req.body.to || '').trim();
const subject = String(req.body.subject || '').trim();
const body = String(req.body.body || '');
if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz'));
}
// Selected generated attachments (checkbox values = anhaenge ids).
let anhangIds = req.body.anhang || [];
if (!Array.isArray(anhangIds)) anhangIds = [anhangIds];
const attachments = [];
const attNames = [];
for (const aid of anhangIds) {
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [aid, id]);
if (!a) continue;
const p = path.join(anhaengeDir, a.pfad);
if (!fs.existsSync(p)) continue;
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
attNames.push(a.dateiname);
}
// Threading headers when this is a reply to a stored message.
let inReplyTo = null, references = null;
if (req.body.reply_to) {
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.reply_to, id]);
if (orig && orig.message_id) {
inReplyTo = '<' + orig.message_id + '>';
references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim();
}
}
const info = await mailer.sendMail({
to, subject, text: body, attachments,
inReplyTo, references,
});
const mid = String(info.messageId || '').replace(/[<>]/g, '');
await dbRun(
`INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references,
from_addr, to_addr, subject, body_text, attachments_json, seen, email_date)
VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null,
mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()]
);
await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ?', [to, id]);
res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz'));
} catch (error) {
console.error('Error sending e-mail:', error);
res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz'));
}
});
// AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the
// frontend drops into the reply form for the user to edit before sending.
app.post('/bewerbung/:id/email/ai-reply', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.email_id, id]);
if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
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 },
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings,
hinweise: String(req.body.hinweise || ''),
});
res.json(draft);
} catch (error) {
console.error('Error drafting AI reply:', error);
res.status(500).json({ error: error.message || 'Serverfehler' });
}
});
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
app.post('/email/fetch', async (req, res) => {
const back = req.body.back || req.get('referer') || '/';
try {
await pollInbox();
} catch (e) { /* errors are logged inside pollInbox */ }
res.redirect(back);
});
// Download an attachment that arrived with a received e-mail.
app.get('/email-anhaenge/:id/download', async (req, res) => {
try {
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ?', [req.params.id]);
if (!a) return res.status(404).send('Anhang nicht gefunden');
const p = path.join(emailAnhaengeDir, a.pfad);
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
res.download(p, a.name || a.pfad);
} catch (error) {
console.error('Error downloading e-mail attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Add a timeline entry (status change with date + comment)
app.post('/bewerbung/:id/verlauf', async (req, res) => {
try {
@@ -1111,6 +1392,18 @@ initializeDatabase().then(() => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});
// E-Mail: verify SMTP on startup and poll the IMAP inbox for replies.
if (mailer.isConfigured()) {
mailer.verify()
.then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`))
.catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message));
const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000);
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
} else {
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
}
// Handle 404
app.use((req, res) => {
res.status(404).send('Seite nicht gefunden');