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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user