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>
1597 lines
61 KiB
JavaScript
1597 lines
61 KiB
JavaScript
const express = require('express');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const multer = require('multer');
|
|
|
|
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
|
// (git-ignored) .env file into process.env without overwriting existing vars.
|
|
(function loadEnv() {
|
|
try {
|
|
const envPath = path.join(__dirname, '.env');
|
|
if (!fs.existsSync(envPath)) return;
|
|
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
|
const line = raw.trim();
|
|
if (!line || line.startsWith('#')) continue;
|
|
const eq = line.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = line.slice(0, eq).trim();
|
|
let val = line.slice(eq + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
if (key && !(key in process.env)) process.env[key] = val;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Konnte .env nicht laden:', e.message);
|
|
}
|
|
})();
|
|
|
|
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
|
|
const mailer = require('./lib/mailer');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Shared option lists (used in multiple views)
|
|
const ART_OPTIONS = [
|
|
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
|
|
'Firmenwebsite', 'Post', 'Initiativbewerbung',
|
|
'Arbeitsagentur', 'Sonstiges'
|
|
];
|
|
const STATUS_OPTIONS = [
|
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
|
'Absage', 'Einstellung', 'Keine Rückmeldung'
|
|
];
|
|
// Base document types the user can provide as a foundation for AI tailoring
|
|
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
|
|
|
|
// Pick the application "source" (art) for a browser-captured job. Honour an
|
|
// explicit value from the extension, otherwise infer it from the URL host so a
|
|
// capture from any website is labelled sensibly.
|
|
function deriveArt(url, provided) {
|
|
if (provided && ART_OPTIONS.includes(provided)) return provided;
|
|
const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase();
|
|
if (!host) return 'Sonstiges';
|
|
if (host.includes('indeed')) return 'Indeed';
|
|
if (host.includes('stepstone')) return 'StepStone';
|
|
if (host.includes('arbeitsagentur')) return 'Arbeitsagentur';
|
|
if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal';
|
|
return 'Firmenwebsite';
|
|
}
|
|
|
|
// Middleware
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Allow the browser extension (running on indeed.com) to call the import API.
|
|
// Kept narrow: only the extension-facing endpoints need cross-origin access.
|
|
app.use('/api/indeed-import', (req, res, next) => {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
next();
|
|
});
|
|
|
|
// Set EJS as template engine
|
|
app.set('view engine', 'ejs');
|
|
app.set('views', path.join(__dirname, 'views'));
|
|
|
|
// Ensure data directory exists
|
|
const dataDir = path.join(__dirname, 'data');
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
|
|
// Directory for generated attachment files (application documents)
|
|
const anhaengeDir = path.join(dataDir, 'anhaenge');
|
|
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');
|
|
if (!fs.existsSync(basisAnhaengeDir)) {
|
|
fs.mkdirSync(basisAnhaengeDir, { recursive: true });
|
|
}
|
|
|
|
// Multipart upload for those static attachments
|
|
const uploadBasisAnhang = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, basisAnhaengeDir),
|
|
filename: (req, file, cb) => {
|
|
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
cb(null, `${Date.now()}_${safe}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
|
}).single('datei');
|
|
|
|
// Directory + uploader for the applicant's signature image (used in the letter)
|
|
const signaturDir = path.join(dataDir, 'signatur');
|
|
if (!fs.existsSync(signaturDir)) {
|
|
fs.mkdirSync(signaturDir, { recursive: true });
|
|
}
|
|
const uploadSignatur = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, signaturDir),
|
|
filename: (req, file, cb) => {
|
|
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
|
cb(null, `signatur_${Date.now()}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
|
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
|
}).single('signatur');
|
|
|
|
// The single stored signature file, if any.
|
|
function currentSignaturFile() {
|
|
try {
|
|
const files = fs.readdirSync(signaturDir).filter((f) => !f.startsWith('.'));
|
|
return files.length ? path.join(signaturDir, files[0]) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Read the signature as a data URL + jsPDF format, for embedding in the letter.
|
|
function loadSignatur() {
|
|
const file = currentSignaturFile();
|
|
if (!file) return null;
|
|
try {
|
|
const ext = path.extname(file).toLowerCase();
|
|
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
|
|
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
|
|
const b64 = fs.readFileSync(file).toString('base64');
|
|
return { dataUrl: `data:${mime};base64,${b64}`, format };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Directory + uploader for the applicant's portrait photo (used in the CV)
|
|
const fotoDir = path.join(dataDir, 'bewerberfoto');
|
|
if (!fs.existsSync(fotoDir)) {
|
|
fs.mkdirSync(fotoDir, { recursive: true });
|
|
}
|
|
const uploadFoto = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, fotoDir),
|
|
filename: (req, file, cb) => {
|
|
const ext = (path.extname(file.originalname) || '.png').toLowerCase();
|
|
cb(null, `foto_${Date.now()}${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
|
|
fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)),
|
|
}).single('foto');
|
|
|
|
// The single stored photo file, if any.
|
|
function currentFotoFile() {
|
|
try {
|
|
const files = fs.readdirSync(fotoDir).filter((f) => !f.startsWith('.'));
|
|
return files.length ? path.join(fotoDir, files[0]) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Read the photo as a data URL + jsPDF format, for embedding in the CV.
|
|
function loadFoto() {
|
|
const file = currentFotoFile();
|
|
if (!file) return null;
|
|
try {
|
|
const ext = path.extname(file).toLowerCase();
|
|
const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG';
|
|
const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png';
|
|
const b64 = fs.readFileSync(file).toString('base64');
|
|
return { dataUrl: `data:${mime};base64,${b64}`, format };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Database setup
|
|
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
|
const db = new sqlite3.Database(dbPath);
|
|
|
|
// Sanitize input to prevent XSS
|
|
function sanitizeInput(input) {
|
|
if (typeof input !== 'string') return input;
|
|
return input
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Promise wrapper for db operations
|
|
function dbGet(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(sql, params, (err, result) => {
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function dbAll(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all(sql, params, (err, results) => {
|
|
if (err) reject(err);
|
|
else resolve(results);
|
|
});
|
|
});
|
|
}
|
|
|
|
function dbRun(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(sql, params, function(err) {
|
|
if (err) reject(err);
|
|
else resolve({ lastID: this.lastID, changes: this.changes });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Duplicate-application guard: spot an existing application for the same job so
|
|
// the user doesn't accidentally apply twice. Matches on a normalised source URL
|
|
// (strongest signal for imported postings) or an identical company + role. It
|
|
// only warns — legitimate re-applications stay possible via a "force" flag.
|
|
// ---------------------------------------------------------------------------
|
|
function normText(s) {
|
|
return String(s == null ? '' : s)
|
|
.toLowerCase()
|
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …)
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function normUrl(u) {
|
|
const raw = String(u == null ? '' : u).trim();
|
|
if (!raw) return '';
|
|
try {
|
|
const url = new URL(raw);
|
|
const host = url.hostname.replace(/^www\./, '').toLowerCase();
|
|
// A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting
|
|
// regardless of tracking params or which path it was opened from.
|
|
const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id'];
|
|
let idPart = '';
|
|
for (const [k, v] of url.searchParams.entries()) {
|
|
if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; }
|
|
}
|
|
const pathn = url.pathname.replace(/\/+$/, '').toLowerCase();
|
|
return idPart ? host + '|' + idPart : host + pathn;
|
|
} catch (e) {
|
|
return raw.toLowerCase().replace(/[?#].*$/, '');
|
|
}
|
|
}
|
|
|
|
// Existing applications that look like the same job as {firma, stelle, quelle_url}.
|
|
// `excludeId` skips a specific row (e.g. when re-checking during an edit).
|
|
async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) {
|
|
const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen');
|
|
const fUrl = normUrl(quelle_url);
|
|
const fFirma = normText(firma);
|
|
const fStelle = normText(stelle);
|
|
const matches = [];
|
|
for (const r of rows) {
|
|
if (excludeId && Number(r.id) === Number(excludeId)) continue;
|
|
let reason = null;
|
|
if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url';
|
|
else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle';
|
|
if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason });
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
// Recompute an application's current status from its latest timeline entry
|
|
async function syncCurrentStatus(bewerbungId) {
|
|
const latest = await dbGet(
|
|
'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
|
|
[bewerbungId]
|
|
);
|
|
await dbRun(
|
|
'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[latest ? latest.status : '', bewerbungId]
|
|
);
|
|
}
|
|
|
|
// Attach the status timeline to each application (single query, grouped in JS)
|
|
async function attachVerlauf(applications) {
|
|
if (!applications.length) return applications;
|
|
const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC');
|
|
const byApp = {};
|
|
all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
|
|
applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
|
|
return applications;
|
|
}
|
|
|
|
// Run the AI document generation for one application (async, fire-and-forget).
|
|
// Loads the base documents + user settings, asks the LLM to tailor them to the
|
|
// job, writes the resulting PDFs to disk and links them as attachments.
|
|
async function runGeneration(bewerbungId) {
|
|
try {
|
|
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
|
if (!bewerbung) return;
|
|
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
|
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
|
|
|
const { documents, email } = await generateApplicationDocuments({
|
|
job: {
|
|
firma: bewerbung.firma,
|
|
stelle: bewerbung.stelle,
|
|
ort: bewerbung.ort,
|
|
quelle_url: bewerbung.quelle_url,
|
|
stellenbeschreibung: bewerbung.stellenbeschreibung,
|
|
},
|
|
basisDokumente,
|
|
settings,
|
|
// Names of the static attachments so the cover letter can list them under "Anlagen".
|
|
zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname),
|
|
// Free-text notes (company address, contact person, extra context) for the LLM.
|
|
llmNotizen: bewerbung.llm_notizen || '',
|
|
// Signature image placed under the closing salutation (instead of the typed name).
|
|
signatur: loadSignatur(),
|
|
// Applicant photo placed in the CV header (top-right), optional.
|
|
bewerbungsfoto: loadFoto(),
|
|
});
|
|
|
|
let seq = 0;
|
|
const storeAnhang = async (name, filename, mime, buffer) => {
|
|
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
|
fs.writeFileSync(path.join(anhaengeDir, stored), buffer);
|
|
await dbRun(
|
|
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
|
[bewerbungId, name, filename, mime, stored]
|
|
);
|
|
};
|
|
|
|
// Generated (AI) documents
|
|
for (const doc of documents) {
|
|
await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
|
|
}
|
|
|
|
// Static extra attachments (e.g. Zeugnisse) — copied as-is
|
|
for (const ba of basisAnhaenge) {
|
|
const src = path.join(basisAnhaengeDir, ba.pfad);
|
|
if (!fs.existsSync(src)) continue;
|
|
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
|
}
|
|
|
|
await dbRun(
|
|
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, " +
|
|
"email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
[(email && email.betreff) || '', (email && email.text) || '', bewerbungId]
|
|
);
|
|
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
|
|
} catch (error) {
|
|
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
|
|
await dbRun(
|
|
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
[String(error.message || 'Unbekannter Fehler'), bewerbungId]
|
|
).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Initialize database - create tables and default settings in one operation
|
|
function initializeDatabase() {
|
|
return new Promise((resolve, reject) => {
|
|
db.serialize(() => {
|
|
db.run('PRAGMA foreign_keys = ON');
|
|
|
|
// Create tables
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS bewerbungen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
datum DATE NOT NULL,
|
|
firma TEXT NOT NULL,
|
|
stelle TEXT NOT NULL,
|
|
art TEXT,
|
|
status TEXT,
|
|
notizen TEXT,
|
|
interne_notizen TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Migration: add columns to pre-existing databases (ignore "duplicate column")
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN email_betreff TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN email_anschreiben TEXT', () => {
|
|
db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => {
|
|
|
|
// Chronological status changes, each with an optional comment
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS status_verlauf (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bewerbung_id INTEGER NOT NULL,
|
|
datum DATE NOT NULL,
|
|
status TEXT NOT NULL,
|
|
kommentar TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS basis_dokumente (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
typ TEXT,
|
|
name TEXT,
|
|
inhalt TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Static extra attachments (e.g. Zeugnisse) attached to every application
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS basis_anhaenge (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT,
|
|
dateiname TEXT NOT NULL,
|
|
mime TEXT,
|
|
pfad TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Generated attachment files linked to an application
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS anhaenge (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bewerbung_id INTEGER NOT NULL,
|
|
name TEXT,
|
|
dateiname TEXT NOT NULL,
|
|
mime TEXT,
|
|
pfad TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
|
)
|
|
`, (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),
|
|
name TEXT,
|
|
adresse TEXT,
|
|
kundennummer TEXT
|
|
)
|
|
`, (err) => {
|
|
if (err) return reject(err);
|
|
|
|
// Insert default settings if not exists
|
|
db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => {
|
|
if (err) return reject(err);
|
|
|
|
if (result && result.count === 0) {
|
|
db.run(
|
|
'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)',
|
|
['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''],
|
|
(err) => {
|
|
if (err) return reject(err);
|
|
resolve();
|
|
}
|
|
);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize and start server
|
|
initializeDatabase().then(() => {
|
|
console.log('Database initialized successfully');
|
|
|
|
// Routes
|
|
app.get('/', async (req, res) => {
|
|
try {
|
|
const { month, year } = req.query;
|
|
|
|
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC';
|
|
const params = [];
|
|
|
|
if (month && year) {
|
|
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
|
params.push(month.padStart(2, '0'), year);
|
|
} else if (year) {
|
|
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
|
|
params.push(year);
|
|
}
|
|
|
|
const applications = await dbAll(query, params);
|
|
await attachVerlauf(applications);
|
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
|
|
|
// Get statistics
|
|
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
|
const byArt = await dbAll(`
|
|
SELECT art, COUNT(*) as count FROM bewerbungen
|
|
WHERE art IS NOT NULL AND art != ''
|
|
GROUP BY art ORDER BY count DESC
|
|
`);
|
|
const byStatus = await dbAll(`
|
|
SELECT status, COUNT(*) as count FROM bewerbungen
|
|
WHERE status IS NOT NULL AND status != ''
|
|
GROUP BY status ORDER BY count DESC
|
|
`);
|
|
|
|
// Get available months/years for filter
|
|
const availableMonths = await dbAll(`
|
|
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
|
|
strftime("%m", datum) as month,
|
|
strftime("%Y", datum) as year
|
|
FROM bewerbungen ORDER BY datum DESC
|
|
`);
|
|
|
|
res.render('index', {
|
|
applications,
|
|
settings,
|
|
statistics: {
|
|
total: totalCount ? totalCount.count : 0,
|
|
byArt,
|
|
byStatus
|
|
},
|
|
availableMonths,
|
|
currentFilter: { month, year },
|
|
artOptions: ART_OPTIONS,
|
|
statusOptions: STATUS_OPTIONS
|
|
});
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Get single application
|
|
app.get('/api/bewerbungen/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
|
|
|
if (!application) {
|
|
return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
|
}
|
|
|
|
res.json(application);
|
|
} catch (error) {
|
|
console.error('Error getting application:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Get settings
|
|
app.get('/api/settings', async (req, res) => {
|
|
try {
|
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
|
res.json(settings);
|
|
} catch (error) {
|
|
console.error('Error getting settings:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Save settings
|
|
app.post('/api/settings', async (req, res) => {
|
|
try {
|
|
const { name, adresse, kundennummer } = req.body;
|
|
|
|
await dbRun(
|
|
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
|
|
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
|
);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error saving settings:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// ----- Indeed import (called by the browser extension) -----
|
|
app.post('/api/indeed-import', async (req, res) => {
|
|
try {
|
|
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
|
|
|
if (!firma || !stelle) {
|
|
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
|
|
}
|
|
|
|
// Source of the capture: honour an explicit art, else infer from the URL.
|
|
const quelle = deriveArt(quelle_url, art);
|
|
|
|
// Duplicate guard: don't silently import the same posting twice.
|
|
const forceImport = req.body.force === true || req.body.force === 'true';
|
|
if (!forceImport) {
|
|
const dups = await findDuplicateApplications({ firma, stelle, quelle_url });
|
|
if (dups.length) {
|
|
return res.status(409).json({
|
|
duplicate: true,
|
|
matches: dups,
|
|
error: 'Für diese Stelle existiert bereits eine Bewerbung.',
|
|
});
|
|
}
|
|
}
|
|
|
|
const datum = new Date().toISOString().split('T')[0];
|
|
// Keep the extra details (location, salary, source) visible in the notes too.
|
|
const notizParts = [
|
|
ort ? `Ort: ${ort}` : null,
|
|
gehalt ? `Gehalt: ${gehalt}` : null,
|
|
quelle_url ? `Quelle: ${quelle_url}` : null,
|
|
].filter(Boolean);
|
|
const notizen = notizParts.join('\n');
|
|
|
|
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
|
|
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
|
|
// (no HTML entities leaking into the generated documents).
|
|
const result = await dbRun(
|
|
`INSERT INTO bewerbungen
|
|
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
|
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
|
[datum, firma, stelle, quelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
|
);
|
|
|
|
// Record the initial "Entwurf" status in the timeline
|
|
await dbRun(
|
|
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
|
[result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`]
|
|
);
|
|
|
|
// Note: generation is NOT started automatically — the user reviews the draft,
|
|
// adds LLM notes if needed, and triggers generation on the application page.
|
|
res.json({
|
|
success: true,
|
|
id: result.lastID,
|
|
url: `/bewerbung/${result.lastID}`,
|
|
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
|
|
});
|
|
} catch (error) {
|
|
console.error('Error importing job:', error);
|
|
res.status(500).json({ error: 'Serverfehler beim Import.' });
|
|
}
|
|
});
|
|
|
|
// Poll generation status + current attachments for one application
|
|
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const bewerbung = await dbGet(
|
|
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id]
|
|
);
|
|
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
|
const anhaenge = await dbAll(
|
|
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id]
|
|
);
|
|
res.json({
|
|
status: bewerbung.generierung_status,
|
|
fehler: bewerbung.generierung_fehler,
|
|
anhaenge,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching generation status:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
|
|
app.get('/api/basis-dokumente', async (req, res) => {
|
|
try {
|
|
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
|
res.json(docs);
|
|
} catch (error) {
|
|
console.error('Error listing base documents:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Create application
|
|
app.post('/api/bewerbungen', async (req, res) => {
|
|
try {
|
|
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
|
|
|
// Duplicate guard: warn before creating a second application for the same
|
|
// company + role (the client re-submits with force=true to confirm).
|
|
const force = req.body.force === true || req.body.force === 'true';
|
|
if (!force) {
|
|
const dups = await findDuplicateApplications({ firma, stelle });
|
|
if (dups.length) {
|
|
return res.status(409).json({
|
|
duplicate: true,
|
|
matches: dups,
|
|
error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.',
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = await dbRun(
|
|
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
|
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)]
|
|
);
|
|
|
|
// Record the initial status as the first timeline entry
|
|
if (status && status.trim()) {
|
|
await dbRun(
|
|
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
|
[result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
|
);
|
|
}
|
|
|
|
const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
|
|
|
res.json({ success: true, application: newApplication });
|
|
} catch (error) {
|
|
console.error('Error creating application:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Update application
|
|
app.put('/api/bewerbungen/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { datum, firma, stelle, art, status, notizen } = req.body;
|
|
|
|
await dbRun(
|
|
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
|
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
|
|
);
|
|
|
|
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
|
|
|
res.json({ success: true, application: updatedApplication });
|
|
} catch (error) {
|
|
console.error('Error updating application:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Delete application
|
|
app.delete('/api/bewerbungen/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
|
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting application:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// Applications for PDF export (optionally filtered), including the status timeline
|
|
app.get('/api/export', async (req, res) => {
|
|
try {
|
|
const { month, year } = req.query;
|
|
|
|
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
|
|
const params = [];
|
|
|
|
if (month && year) {
|
|
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
|
params.push(month.padStart(2, '0'), year);
|
|
} else if (month) {
|
|
// A month without a year must still restrict the export to that month —
|
|
// never fall through to exporting every application.
|
|
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
|
|
params.push(month.padStart(2, '0'));
|
|
} else if (year) {
|
|
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
|
|
params.push(year);
|
|
}
|
|
|
|
const applications = await dbAll(query, params);
|
|
await attachVerlauf(applications);
|
|
// Internal notes must never reach the PDF/export
|
|
applications.forEach((a) => { delete a.interne_notizen; });
|
|
|
|
res.json(applications);
|
|
} catch (error) {
|
|
console.error('Error exporting applications:', error);
|
|
res.status(500).json({ error: 'Serverfehler' });
|
|
}
|
|
});
|
|
|
|
// ----- Dedicated edit page + status-timeline management -----
|
|
|
|
// Edit page for a single application
|
|
app.get('/bewerbung/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
|
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
|
|
|
|
const verlauf = await dbAll(
|
|
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
|
[id]
|
|
);
|
|
|
|
const anhaenge = await dbAll(
|
|
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
|
[id]
|
|
);
|
|
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,
|
|
hideSettings: true
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading edit page:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Update application core data (status is managed via the timeline)
|
|
app.post('/bewerbung/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
|
|
|
|
await dbRun(
|
|
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id]
|
|
);
|
|
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error updating application:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Save the editable e-mail cover text (Begleit-E-Mail) after the user tweaks it.
|
|
app.post('/bewerbung/:id/email', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
|
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
|
|
|
// Store raw; the value is HTML-escaped on render (EJS <%= %>), matching how
|
|
// the generated e-mail is stored. Sanitising here would double-escape.
|
|
await dbRun(
|
|
'UPDATE bewerbungen SET email_betreff = ?, email_anschreiben = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
[String(req.body.email_betreff || ''), String(req.body.email_anschreiben || ''), id]
|
|
);
|
|
res.redirect('/bewerbung/' + id + '#email');
|
|
} catch (error) {
|
|
console.error('Error saving e-mail text:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// 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');
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { datum, status, kommentar } = req.body;
|
|
|
|
if (datum && status && status.trim()) {
|
|
await dbRun(
|
|
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
|
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
|
);
|
|
await syncCurrentStatus(id);
|
|
}
|
|
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error adding timeline entry:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Update a timeline entry
|
|
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
|
try {
|
|
const { id, eintragId } = req.params;
|
|
const { datum, status, kommentar } = req.body;
|
|
|
|
if (datum && status && status.trim()) {
|
|
await dbRun(
|
|
'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?',
|
|
[datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id]
|
|
);
|
|
await syncCurrentStatus(id);
|
|
}
|
|
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error updating timeline entry:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Delete a timeline entry
|
|
app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
|
|
try {
|
|
const { id, eintragId } = req.params;
|
|
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]);
|
|
await syncCurrentStatus(id);
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error deleting timeline entry:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// ----- Vorlagen (Basis-Unterlagen) management page -----
|
|
app.get('/vorlagen', async (req, res) => {
|
|
try {
|
|
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
|
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
|
res.render('vorlagen', {
|
|
basisDokumente,
|
|
basisAnhaenge,
|
|
hasSignatur: Boolean(currentSignaturFile()),
|
|
hasFoto: Boolean(currentFotoFile()),
|
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
|
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
|
hideSettings: true,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading vorlagen:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Add a base document
|
|
app.post('/vorlagen', async (req, res) => {
|
|
try {
|
|
const { typ, name, inhalt } = req.body;
|
|
if (inhalt && inhalt.trim()) {
|
|
await dbRun(
|
|
'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)',
|
|
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
|
|
);
|
|
}
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error adding base document:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Update a base document
|
|
app.post('/vorlagen/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { typ, name, inhalt } = req.body;
|
|
await dbRun(
|
|
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?',
|
|
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id]
|
|
);
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error updating base document:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Delete a base document
|
|
app.post('/vorlagen/:id/delete', async (req, res) => {
|
|
try {
|
|
await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]);
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error deleting base document:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// ----- Static extra attachments (Zeugnisse etc.) -----
|
|
|
|
// Upload an attachment
|
|
app.post('/anlagen', (req, res) => {
|
|
uploadBasisAnhang(req, res, async (err) => {
|
|
try {
|
|
if (err) {
|
|
console.error('Upload error:', err.message);
|
|
return res.redirect('/vorlagen');
|
|
}
|
|
if (req.file) {
|
|
const original = req.file.originalname || req.file.filename;
|
|
const name = (req.body.name && req.body.name.trim())
|
|
? sanitizeInput(req.body.name.trim())
|
|
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
|
await dbRun(
|
|
'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)',
|
|
[name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
|
);
|
|
}
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error saving attachment:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Download a static attachment
|
|
app.get('/anlagen/:id/download', async (req, res) => {
|
|
try {
|
|
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
|
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
|
const filePath = path.join(basisAnhaengeDir, a.pfad);
|
|
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
|
res.download(filePath, a.dateiname);
|
|
} catch (error) {
|
|
console.error('Error downloading attachment:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Delete a static attachment
|
|
app.post('/anlagen/:id/delete', async (req, res) => {
|
|
try {
|
|
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
|
if (a) {
|
|
fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {});
|
|
await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
|
}
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error deleting attachment:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// ----- Signature (Unterschrift) -----
|
|
|
|
// Serve the current signature image (for the preview on the Vorlagen page)
|
|
app.get('/unterschrift', (req, res) => {
|
|
const file = currentSignaturFile();
|
|
if (!file) return res.status(404).send('Keine Unterschrift');
|
|
res.sendFile(file);
|
|
});
|
|
|
|
// Upload / replace the signature
|
|
app.post('/unterschrift', (req, res) => {
|
|
uploadSignatur(req, res, (err) => {
|
|
try {
|
|
if (err) console.error('Signature upload error:', err.message);
|
|
if (req.file) {
|
|
// keep only the newly uploaded file
|
|
fs.readdirSync(signaturDir).forEach((f) => {
|
|
if (f !== req.file.filename) fs.promises.unlink(path.join(signaturDir, f)).catch(() => {});
|
|
});
|
|
}
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error saving signature:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Delete the signature
|
|
app.post('/unterschrift/delete', (req, res) => {
|
|
try {
|
|
fs.readdirSync(signaturDir).forEach((f) => fs.promises.unlink(path.join(signaturDir, f)).catch(() => {}));
|
|
} catch (e) { /* ignore */ }
|
|
res.redirect('/vorlagen');
|
|
});
|
|
|
|
// ----- Applicant photo (Bewerberfoto, used in the CV) -----
|
|
|
|
// Serve the current photo (for the preview on the Vorlagen page)
|
|
app.get('/bewerbungsfoto', (req, res) => {
|
|
const file = currentFotoFile();
|
|
if (!file) return res.status(404).send('Kein Bewerberfoto');
|
|
res.sendFile(file);
|
|
});
|
|
|
|
// Upload / replace the photo
|
|
app.post('/bewerbungsfoto', (req, res) => {
|
|
uploadFoto(req, res, (err) => {
|
|
try {
|
|
if (err) console.error('Photo upload error:', err.message);
|
|
if (req.file) {
|
|
// keep only the newly uploaded file
|
|
fs.readdirSync(fotoDir).forEach((f) => {
|
|
if (f !== req.file.filename) fs.promises.unlink(path.join(fotoDir, f)).catch(() => {});
|
|
});
|
|
}
|
|
res.redirect('/vorlagen');
|
|
} catch (error) {
|
|
console.error('Error saving photo:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Delete the photo
|
|
app.post('/bewerbungsfoto/delete', (req, res) => {
|
|
try {
|
|
fs.readdirSync(fotoDir).forEach((f) => fs.promises.unlink(path.join(fotoDir, f)).catch(() => {}));
|
|
} catch (e) { /* ignore */ }
|
|
res.redirect('/vorlagen');
|
|
});
|
|
|
|
// Download a generated attachment
|
|
app.get('/anhaenge/:id/download', async (req, res) => {
|
|
try {
|
|
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]);
|
|
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
|
|
const filePath = path.join(anhaengeDir, anhang.pfad);
|
|
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
|
res.download(filePath, anhang.dateiname);
|
|
} catch (error) {
|
|
console.error('Error downloading attachment:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Delete a generated attachment
|
|
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
|
|
try {
|
|
const { id, anhangId } = req.params;
|
|
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]);
|
|
if (anhang) {
|
|
const filePath = path.join(anhaengeDir, anhang.pfad);
|
|
fs.promises.unlink(filePath).catch(() => {});
|
|
await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]);
|
|
}
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error deleting attachment:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
|
// first, removes any previously generated attachments, then generates.
|
|
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
|
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
|
|
|
// Persist the LLM notes the user provided (used as context during generation)
|
|
if (typeof req.body.llm_notizen !== 'undefined') {
|
|
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
|
}
|
|
|
|
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
|
for (const a of alte) {
|
|
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
|
}
|
|
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
|
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]);
|
|
|
|
runGeneration(id);
|
|
res.redirect('/bewerbung/' + id);
|
|
} catch (error) {
|
|
console.error('Error generating documents:', error);
|
|
res.status(500).send('Serverfehler');
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
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');
|
|
});
|
|
}).catch((err) => {
|
|
console.error('Failed to initialize database:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Close database on exit
|
|
process.on('SIGINT', () => {
|
|
db.close();
|
|
process.exit();
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
db.close();
|
|
process.exit();
|
|
});
|