diff --git a/.env.example b/.env.example index 92df284..3b75529 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,18 @@ OLLAMA_API_KEY= # Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434) # OLLAMA_HOST=https://ollama.com + +# --- E-Mail (SMTP-Versand + IMAP-Empfang) --- +# Versand läuft über die authentifizierte Submission des eigenen Mailservers, +# damit dieser DKIM signiert und die reputable IP/PTR nutzt (SPF/DMARC-Alignment). +# Ohne diese Angaben ist der E-Mail-Teil deaktiviert. +MAIL_HOST=mail.example.com +MAIL_SMTP_PORT=587 +MAIL_IMAP_PORT=993 +MAIL_USER=name@example.com +MAIL_PASSWORD= +MAIL_FROM_NAME=Vorname Nachname +MAIL_FROM=name@example.com +# IMAP-Postfach + Abrufintervall (ms) für neue Antwortmails +MAIL_IMAP_MAILBOX=INBOX +MAIL_POLL_MS=180000 diff --git a/lib/documents.js b/lib/documents.js index 2473aca..d664b19 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -1013,8 +1013,101 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus return { documents, email: data.email || { betreff: '', text: '' } }; } +// =========================================================================== +// AI reply drafting — draft a professional German reply to an incoming email +// =========================================================================== + +// Small shared Ollama JSON call (used by the reply drafter). +async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) { + const apiKey = process.env.OLLAMA_API_KEY; + if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); + let res; + try { + res = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + stream: false, + format: schema, + options: { temperature }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }), + signal: controller.signal, + }); + } catch (err) { + if (err.name === 'AbortError') throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); + throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); + } finally { + clearTimeout(timeout); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); + } + const data = await res.json(); + const textOut = ((data && data.message && data.message.content) || '').trim(); + if (!textOut) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); + const cleaned = textOut.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); + try { return JSON.parse(cleaned); } catch (e) { + const m = cleaned.match(/\{[\s\S]*\}/); + if (m) { try { return JSON.parse(m[0]); } catch (_) { /* ignore */ } } + } + throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.'); +} + +const REPLY_SCHEMA = { + type: 'object', + properties: { betreff: { type: 'string' }, text: { type: 'string' } }, + required: ['betreff', 'text'], +}; + +// Draft a reply to a recruiter/company e-mail in the context of an application. +// `incoming` = { from, subject, text }; `job` = { firma, stelle }; +// `settings` = { name, ... }. `hinweise` = optional free-text steering. +async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '' }) { + const name = (settings && settings.name) || 'der Bewerber'; + const system = + 'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' + + 'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' + + 'Du antwortest konkret auf die eingegangene Nachricht. Wichtig: Du erfindest KEINE ' + + 'Fakten (keine erfundenen Termine, Zahlen, Zusagen). Wenn eine konkrete Angabe nötig ist, ' + + 'die du nicht kennst (z. B. ein genauer Terminvorschlag), setze einen klar erkennbaren ' + + 'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' + + 'knapp und verbindlich, ohne Floskeln.'; + const user = + `# Kontext der Bewerbung\n` + + `Unternehmen: ${job.firma || '-'}\n` + + `Stelle: ${job.stelle || '-'}\n` + + `Bewerber: ${name}\n\n` + + `# Eingegangene E-Mail\n` + + `Von: ${incoming.from || '-'}\n` + + `Betreff: ${incoming.subject || '-'}\n\n` + + `${incoming.text || ''}\n\n` + + (hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') + + `# Aufgabe\n` + + `Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` + + `konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` + + `2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` + + `Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` + + `"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` + + `Verwende ausschließlich den einfachen Bindestrich "-" (niemals – oder —). ` + + `Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`; + const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 }); + return { + betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || ('Re: ' + (incoming.subject || '')), + text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])), + }; +} + module.exports = { generateApplicationDocuments, + generateEmailReply, generateTailoredTexts, renderLebenslaufPdf, renderAnschreibenPdf, diff --git a/lib/mailer.js b/lib/mailer.js new file mode 100644 index 0000000..d5e12df --- /dev/null +++ b/lib/mailer.js @@ -0,0 +1,179 @@ +// E-Mail transport: authenticated SMTP submission (send) + IMAP (receive). +// +// Deliverability by design: all mail is sent through the account's own +// submission server (MAIL_HOST) with SMTP AUTH, so the server applies its +// DKIM signature and uses its reputable IP/PTR. Combined with a From address +// on the same domain, this satisfies SPF and DKIM alignment — required here +// because the domain publishes SPF "-all" and DMARC "p=reject". Sending mail +// directly to the recipient MX (bypassing the submission server) would fail +// both and land in spam / get rejected, so we never do that. + +const nodemailer = require('nodemailer'); +const { ImapFlow } = require('imapflow'); +const { simpleParser } = require('mailparser'); + +function cfg() { + const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587); + return { + host: process.env.MAIL_HOST || '', + smtpPort, + imapPort: Number(process.env.MAIL_IMAP_PORT || 993), + user: process.env.MAIL_USER || '', + pass: process.env.MAIL_PASSWORD || '', + fromName: process.env.MAIL_FROM_NAME || '', + fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '', + mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX', + // secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS). + smtpSecure: smtpPort === 465, + }; +} + +function isConfigured() { + const c = cfg(); + return Boolean(c.host && c.user && c.pass); +} + +// "Anzeigename " for the From header. +function fromField() { + const c = cfg(); + return c.fromName ? `${c.fromName} <${c.fromAddr}>` : c.fromAddr; +} +function fromAddress() { + return cfg().fromAddr; +} + +function buildTransport() { + const c = cfg(); + return nodemailer.createTransport({ + host: c.host, + port: c.smtpPort, + secure: c.smtpSecure, + requireTLS: !c.smtpSecure, // enforce STARTTLS on 587 + auth: { user: c.user, pass: c.pass }, + // Present a sane FQDN in EHLO (matches the sending domain). + name: (c.fromAddr.split('@')[1] || c.host || undefined), + }); +} + +// Verify SMTP connectivity + credentials (used by a health check / startup log). +async function verify() { + const t = buildTransport(); + try { await t.verify(); return true; } finally { t.close(); } +} + +// Minimal HTML rendering of a plain-text body: escape, then turn newlines into +//
. Sending multipart/alternative (text + html) reads as normal personal +// mail and avoids the "text-only, no html" heuristic some filters apply. +function textToHtml(text) { + const esc = String(text || '') + .replace(/&/g, '&').replace(//g, '>'); + return `
${esc}
`; +} + +// Send one message via authenticated submission. Returns nodemailer info +// (includes messageId). `attachments` is an array of {filename, path|content, contentType}. +async function sendMail({ to, subject, text, html, attachments, inReplyTo, references, cc, bcc }) { + const t = buildTransport(); + try { + const headers = {}; + if (inReplyTo) headers['In-Reply-To'] = inReplyTo; + if (references) headers['References'] = references; + return await t.sendMail({ + from: fromField(), + to, + cc: cc || undefined, + bcc: bcc || undefined, + subject: subject || '', + text: text || '', + html: html || textToHtml(text), + attachments: attachments || [], + headers, + // Message-ID auto-generated on the From domain → clean threading + DMARC. + }); + } finally { + t.close(); + } +} + +// Fetch messages from the mailbox with a UID strictly greater than `sinceUid`. +// Returns { messages: [...parsed...], maxUid }. Each parsed message exposes the +// UID plus the fields we persist. Guards the classic IMAP "N:*" quirk (a range +// whose start exceeds the highest UID still returns the last message) by +// filtering uid > sinceUid in code. +async function fetchSince(sinceUid = 0) { + const c = cfg(); + const client = new ImapFlow({ + host: c.host, port: c.imapPort, secure: true, + auth: { user: c.user, pass: c.pass }, + logger: false, + }); + const messages = []; + let maxUid = sinceUid; + await client.connect(); + const lock = await client.getMailboxLock(c.mailbox); + try { + const box = client.mailbox; + if (box && box.exists > 0) { + const from = Math.max(1, Number(sinceUid) + 1); + for await (const msg of client.fetch(`${from}:*`, { uid: true, source: true, flags: true }, { uid: true })) { + if (msg.uid <= sinceUid) continue; // dodge the N:* quirk + if (msg.uid > maxUid) maxUid = msg.uid; + let parsed = null; + try { parsed = await simpleParser(msg.source); } catch (e) { parsed = null; } + if (!parsed) continue; + const fromV = (parsed.from && parsed.from.value && parsed.from.value[0]) || {}; + const toText = parsed.to && parsed.to.text ? parsed.to.text : ''; + messages.push({ + uid: msg.uid, + messageId: stripBrackets(parsed.messageId), + inReplyTo: stripBrackets(parsed.inReplyTo), + references: Array.isArray(parsed.references) + ? parsed.references.map(stripBrackets).join(' ') + : stripBrackets(parsed.references), + fromAddr: (fromV.address || '').toLowerCase(), + fromName: fromV.name || '', + toAddr: toText, + subject: parsed.subject || '', + text: parsed.text || (parsed.html ? htmlToText(parsed.html) : ''), + html: parsed.html || '', + date: parsed.date ? new Date(parsed.date) : new Date(), + seen: !!(msg.flags && msg.flags.has && msg.flags.has('\\Seen')), + attachments: (parsed.attachments || []) + .filter((a) => a.content && a.filename) + .map((a) => ({ filename: a.filename, contentType: a.contentType || 'application/octet-stream', content: a.content })), + }); + } + } + } finally { + lock.release(); + await client.logout().catch(() => {}); + } + return { messages, maxUid }; +} + +function stripBrackets(v) { + if (!v) return ''; + return String(v).replace(/[<>]/g, '').trim(); +} + +// Very small HTML→text fallback for html-only mails. +function htmlToText(html) { + return String(html || '') + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\s*\/p\s*>/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +module.exports = { + isConfigured, + verify, + sendMail, + fetchSince, + fromField, + fromAddress, + textToHtml, + config: cfg, +}; diff --git a/package-lock.json b/package-lock.json index e43373c..5b71c6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,12 @@ "dependencies": { "ejs": "^3.1.9", "express": "^4.18.2", + "imapflow": "^1.4.3", "jspdf": "^2.5.1", "jspdf-autotable": "^3.8.2", + "mailparser": "^3.9.12", "multer": "^2.2.0", + "nodemailer": "^9.0.3", "sqlite3": "^5.1.6" }, "devDependencies": { @@ -62,6 +65,28 @@ "node": ">=10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -79,6 +104,17 @@ "license": "MIT", "optional": true }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.13", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.13.tgz", + "integrity": "sha512-j40NeNlSAivqnKEjzZYsGP/bKWr2zwuyb8XOYsC3i0ZlfM5UnTYTa7aCRkE8rYQvVzE1dRjVYdvZ1Epi6x+h6w==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.0", + "libqp": "2.1.1" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -240,6 +276,15 @@ "node": ">= 4.5.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -670,6 +715,15 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -705,6 +759,47 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", @@ -712,6 +807,20 @@ "license": "(MPL-2.0 OR Apache-2.0)", "optional": true }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -773,6 +882,15 @@ "iconv-lite": "^0.6.2" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -795,6 +913,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -1214,6 +1344,34 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -1228,6 +1386,37 @@ "node": ">=8.0.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -1383,6 +1572,48 @@ "dev": true, "license": "ISC" }, + "node_modules/imapflow": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.3.tgz", + "integrity": "sha512-UNimp06TINpFLx7g5nXh/8+oykfaHy6zgvLuQseLifGrQUSqDexuSKrksM+uA9mQw1cYJTOVrFPRTHkK+W/tpw==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.13", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.4.0", + "libqp": "2.1.1", + "nodemailer": "9.0.1", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/imapflow/node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1439,7 +1670,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", - "optional": true, "engines": { "node": ">= 12" } @@ -1567,6 +1797,74 @@ "jspdf": "^2.5.1" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.0.tgz", + "integrity": "sha512-MAWyU2qYtCsrZ6GClgukz2jx73NQrzA6ASo9qzThuTG+A7+H3lWQuBsjoy9lls+v6q/epAeBdEMJ3T0f4b+uRw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1580,6 +1878,49 @@ "node": ">=10" } }, + "node_modules/mailparser": { + "version": "3.9.12", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.12.tgz", + "integrity": "sha512-kbT4xtkKEddonhDTjjWWVFJlI5axm0S9QuIAHs1ue3IEw+fNd9o4ytPKaG7p1LOE2aKifrWLjx/omOGLHz/9RQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.13", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.4.0", + "linkify-it": "5.0.1", + "nodemailer": "9.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", @@ -1906,6 +2247,15 @@ "node": ">= 10.12.0" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -2054,6 +2404,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2091,6 +2450,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2116,6 +2488,15 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -2142,6 +2523,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2169,6 +2587,22 @@ "node": ">=10" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -2220,6 +2654,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2235,6 +2678,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -2311,6 +2760,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -2375,12 +2833,33 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -2593,7 +3072,6 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -2604,7 +3082,6 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", - "optional": true, "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" @@ -2654,6 +3131,24 @@ "license": "MIT", "optional": true }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -2858,6 +3353,33 @@ "utrie": "^1.0.2" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2921,6 +3443,12 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 123f085..3e81744 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "dependencies": { "ejs": "^3.1.9", "express": "^4.18.2", + "imapflow": "^1.4.3", "jspdf": "^2.5.1", "jspdf-autotable": "^3.8.2", + "mailparser": "^3.9.12", "multer": "^2.2.0", + "nodemailer": "^9.0.3", "sqlite3": "^5.1.6" }, "devDependencies": { diff --git a/server.js b/server.js index 6f2f19c..964a5eb 100644 --- a/server.js +++ b/server.js @@ -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 "). + 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'); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 5474bea..13c8e67 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -262,6 +262,142 @@ <% } %> + +
+
+

E-Mail-Korrespondenz

+ <% if (mailConfigured) { %> +
+ + +
+ <% } %> +
+ + <% if (!mailConfigured) { %> +
+ + E-Mail ist nicht konfiguriert. Bitte MAIL_HOST, MAIL_USER und MAIL_PASSWORD in der .env setzen. +
+ <% } else { %> + + <% if (mailError) { %> +
<%= mailError %>
+ <% } %> + <% if (mailOk) { %> +
<%= mailOk %>
+ <% } %> + + + <% if (emails && emails.length) { %> +
    + <% emails.forEach(function(e){ var out = e.direction === 'out'; %> +
  • +
    +
    + <%= out ? 'Gesendet' : 'Empfangen' %> + + <%= out ? ('An: ' + (e.to_addr || '')) : ('Von: ' + (e.from_addr || '')) %> + +
    + <%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %> +
    +

    <%= e.subject || '(kein Betreff)' %>

    +

    <%= e.body_text || '' %>

    + + <% if (e.anhaenge && e.anhaenge.length) { %> +
    + <% e.anhaenge.forEach(function(a){ %> + + + <%= a.name || 'Anhang' %> + + <% }); %> +
    + <% } %> + + <% if (!out) { %> +
    + +
    + + <% } %> +
  • + <% }); %> +
+ <% } else { %> +

Noch keine E-Mails. Sende die Bewerbung unten oder rufe das Postfach ab.

+ <% } %> + + +
+

Bewerbung per E-Mail senden

+

+ Versand über <%= mailFrom %>. Empfängeradresse eingeben, Betreff und Text prüfen, + Anhänge auswählen und senden. +

+
+ + + + + + + + <% if (anhaenge && anhaenge.length) { %> +

Anhänge

+
+ <% anhaenge.forEach(function(a){ %> + + <% }); %> +
+ <% } else { %> +

Keine generierten Anhänge vorhanden - erst Unterlagen generieren.

+ <% } %> + +
+ +
+
+
+ <% } %> +
+

Status-Verlauf

@@ -397,6 +533,44 @@ } catch (e) { /* clipboard blocked — ignore */ } }); + // Toggle inline reply forms under received e-mails. + document.querySelectorAll('.reply-toggle').forEach((btn) => { + btn.addEventListener('click', () => { + const form = document.getElementById(btn.dataset.target); + if (form) form.classList.toggle('hidden'); + }); + }); + + // Generate an AI draft reply and drop it into the reply form. + document.querySelectorAll('.ai-reply-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + const form = document.getElementById(btn.dataset.target); + if (!form) return; + const label = btn.querySelector('.ai-reply-label'); + const prev = label ? label.textContent : ''; + if (label) label.textContent = 'Generiere …'; + btn.disabled = true; + try { + const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email_id: btn.dataset.emailId }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Fehler'); + const body = form.querySelector('.reply-body'); + const subject = form.querySelector('.reply-subject'); + if (body && data.text) body.value = data.text; + if (subject && data.betreff) subject.value = data.betreff; + } catch (e) { + alert('KI-Antwort fehlgeschlagen: ' + e.message); + } finally { + if (label) label.textContent = prev; + btn.disabled = false; + } + }); + }); + // While documents are being generated, poll for completion and // reload the page once the status changes. const unterlagen = document.getElementById('unterlagen');