Add e-mail system: send applications, receive & reply to responses
Send the complete application (body + generated attachments) to a user-entered address via authenticated SMTP submission through the account's own server, so it applies DKIM and uses its reputable IP/PTR — required for deliverability here (domain publishes SPF -all, DMARC p=reject). From/Return-Path stay aligned on the sending domain. Reply e-mails are polled over IMAP, parsed, stored and matched to the right application (via In-Reply-To/References, then sender address); their attachments are saved and downloadable. The detail page gains a correspondence thread with compose, threaded reply, and an AI-drafted reply the user can edit before sending. New: lib/mailer.js (nodemailer + imapflow + mailparser), generateEmailReply in lib/documents.js, emails/email_anhaenge/app_state tables, background poller + manual fetch. Credentials come from MAIL_* env vars (.env, not committed / not in the image). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+179
@@ -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 <adresse>" 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
|
||||
// <br>. 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, '<').replace(/>/g, '>');
|
||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;white-space:pre-wrap">${esc}</div>`;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
Generated
+531
-3
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -27,7 +27,8 @@ const multer = require('multer');
|
||||
}
|
||||
})();
|
||||
|
||||
const { generateApplicationDocuments } = require('./lib/documents');
|
||||
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
|
||||
const mailer = require('./lib/mailer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -76,6 +77,12 @@ if (!fs.existsSync(anhaengeDir)) {
|
||||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Directory for attachments received via IMAP (reply e-mails).
|
||||
const emailAnhaengeDir = path.join(dataDir, 'email_anhaenge');
|
||||
if (!fs.existsSync(emailAnhaengeDir)) {
|
||||
fs.mkdirSync(emailAnhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
|
||||
// and that are sent along with every generated application.
|
||||
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
||||
@@ -221,6 +228,95 @@ function dbRun(sql, params = []) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getState(key) {
|
||||
const row = await dbGet('SELECT value FROM app_state WHERE key = ?', [key]);
|
||||
return row ? row.value : null;
|
||||
}
|
||||
async function setState(key, value) {
|
||||
await dbRun(
|
||||
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[key, String(value)]
|
||||
);
|
||||
}
|
||||
|
||||
// Match an incoming message to an application: first via In-Reply-To/References
|
||||
// pointing at one of our sent messages, then by sender = a previous recipient.
|
||||
async function matchBewerbung(msg) {
|
||||
const refs = []
|
||||
.concat((msg.inReplyTo || '').split(/\s+/))
|
||||
.concat((msg.references || '').split(/\s+/))
|
||||
.map((r) => r.replace(/[<>]/g, '').trim())
|
||||
.filter(Boolean);
|
||||
for (const mid of refs) {
|
||||
const row = await dbGet(
|
||||
"SELECT bewerbung_id FROM emails WHERE direction = 'out' AND message_id = ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
[mid]
|
||||
);
|
||||
if (row && row.bewerbung_id) return row.bewerbung_id;
|
||||
}
|
||||
if (msg.fromAddr) {
|
||||
const row = await dbGet(
|
||||
"SELECT bewerbung_id FROM emails WHERE direction = 'out' AND lower(to_addr) LIKE ? AND bewerbung_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
['%' + msg.fromAddr.toLowerCase() + '%']
|
||||
);
|
||||
if (row && row.bewerbung_id) return row.bewerbung_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let polling = false;
|
||||
// Fetch new mail from the IMAP inbox, persist unseen messages, link them to the
|
||||
// matching application and save their attachments. Safe to call concurrently
|
||||
// (guarded) — used both by the interval poller and the manual "fetch" button.
|
||||
async function pollInbox() {
|
||||
if (!mailer.isConfigured() || polling) return { fetched: 0 };
|
||||
polling = true;
|
||||
try {
|
||||
const lastUid = Number(await getState('mail_last_uid')) || 0;
|
||||
const { messages, maxUid } = await mailer.fetchSince(lastUid);
|
||||
let stored = 0;
|
||||
for (const m of messages) {
|
||||
// Skip if we already have this message (id or uid) — idempotent.
|
||||
if (m.messageId) {
|
||||
const dup = await dbGet('SELECT id FROM emails WHERE message_id = ?', [m.messageId]);
|
||||
if (dup) continue;
|
||||
}
|
||||
const bewId = await matchBewerbung(m);
|
||||
const result = await dbRun(
|
||||
`INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references,
|
||||
from_addr, to_addr, subject, body_text, body_html, imap_uid, seen, email_date)
|
||||
VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||
[bewId, m.messageId || null, m.inReplyTo || null, m.references || null,
|
||||
m.fromName ? `${m.fromName} <${m.fromAddr}>` : m.fromAddr, m.toAddr || '',
|
||||
m.subject || '', m.text || '', m.html || '', m.uid,
|
||||
(m.date instanceof Date ? m.date.toISOString() : new Date().toISOString())]
|
||||
);
|
||||
// Persist attachments to disk + link rows.
|
||||
for (const att of (m.attachments || [])) {
|
||||
const safe = String(att.filename || 'anhang').replace(/[^a-zA-Z0-9äöüÄÖÜß._ -]/g, '_').slice(0, 80);
|
||||
const storedName = `${result.lastID}_${Date.now()}_${safe}`;
|
||||
try {
|
||||
fs.writeFileSync(path.join(emailAnhaengeDir, storedName), att.content);
|
||||
await dbRun('INSERT INTO email_anhaenge (email_id, name, mime, pfad) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, att.filename, att.contentType, storedName]);
|
||||
} catch (e) { /* ignore a single bad attachment */ }
|
||||
}
|
||||
stored++;
|
||||
}
|
||||
if (maxUid > lastUid) await setState('mail_last_uid', maxUid);
|
||||
return { fetched: stored };
|
||||
} catch (err) {
|
||||
console.error('IMAP-Abruf fehlgeschlagen:', err.message);
|
||||
return { fetched: 0, error: err.message };
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute an application's current status from its latest timeline entry
|
||||
async function syncCurrentStatus(bewerbungId) {
|
||||
const latest = await dbGet(
|
||||
@@ -399,6 +495,50 @@ function initializeDatabase() {
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// E-Mail correspondence (sent + received), linked to an application.
|
||||
// Serialized mode guarantees these run after the tables above exist.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS emails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bewerbung_id INTEGER,
|
||||
direction TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
in_reply_to TEXT,
|
||||
email_references TEXT,
|
||||
from_addr TEXT,
|
||||
to_addr TEXT,
|
||||
subject TEXT,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
attachments_json TEXT,
|
||||
imap_uid INTEGER,
|
||||
seen INTEGER DEFAULT 1,
|
||||
email_date DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS email_anhaenge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email_id INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
mime TEXT,
|
||||
pfad TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
// Small key/value store (e.g. last processed IMAP UID).
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
// Remember the last recipient address per application (prefill).
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
@@ -746,10 +886,38 @@ initializeDatabase().then(() => {
|
||||
);
|
||||
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
||||
|
||||
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
||||
const emails = await dbAll(
|
||||
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||
[id]
|
||||
);
|
||||
if (emails.length) {
|
||||
const eIds = emails.map((e) => e.id);
|
||||
const atts = await dbAll(
|
||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||
eIds
|
||||
);
|
||||
const byEmail = {};
|
||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||
emails.forEach((e) => {
|
||||
e.anhaenge = byEmail[e.id] || [];
|
||||
// Bare address for prefilling a reply's "To" (from "Name <addr>").
|
||||
const m = String(e.from_addr || '').match(/<([^>]+)>/);
|
||||
e.from_addr_clean = m ? m[1] : String(e.from_addr || '').trim();
|
||||
});
|
||||
// Mark received messages as read now that they are shown.
|
||||
await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id = ? AND direction = 'in' AND seen = 0", [id]);
|
||||
}
|
||||
|
||||
res.render('bewerbung', {
|
||||
application,
|
||||
verlauf,
|
||||
anhaenge,
|
||||
emails,
|
||||
mailConfigured: mailer.isConfigured(),
|
||||
mailFrom: mailer.isConfigured() ? mailer.fromField() : '',
|
||||
mailError: req.query.mailerror ? String(req.query.mailerror) : '',
|
||||
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||||
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||||
artOptions: ART_OPTIONS,
|
||||
statusOptions: STATUS_OPTIONS,
|
||||
@@ -799,6 +967,119 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Send an e-mail for an application (initial application or a reply). Sends
|
||||
// via authenticated submission, records it as an outgoing message and links
|
||||
// the chosen generated attachments.
|
||||
app.post('/bewerbung/:id/email/send', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const back = (frag) => '/bewerbung/' + id + (frag || '#korrespondenz');
|
||||
try {
|
||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||
if (!mailer.isConfigured()) {
|
||||
return res.redirect(back('?mailerror=' + encodeURIComponent('E-Mail ist nicht konfiguriert (.env).') + '#korrespondenz'));
|
||||
}
|
||||
|
||||
const to = String(req.body.to || '').trim();
|
||||
const subject = String(req.body.subject || '').trim();
|
||||
const body = String(req.body.body || '');
|
||||
if (!to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
|
||||
return res.redirect(back('?mailerror=' + encodeURIComponent('Bitte eine gültige Empfänger-Adresse angeben.') + '#korrespondenz'));
|
||||
}
|
||||
|
||||
// Selected generated attachments (checkbox values = anhaenge ids).
|
||||
let anhangIds = req.body.anhang || [];
|
||||
if (!Array.isArray(anhangIds)) anhangIds = [anhangIds];
|
||||
const attachments = [];
|
||||
const attNames = [];
|
||||
for (const aid of anhangIds) {
|
||||
const a = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [aid, id]);
|
||||
if (!a) continue;
|
||||
const p = path.join(anhaengeDir, a.pfad);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
attachments.push({ filename: a.dateiname, path: p, contentType: a.mime || undefined });
|
||||
attNames.push(a.dateiname);
|
||||
}
|
||||
|
||||
// Threading headers when this is a reply to a stored message.
|
||||
let inReplyTo = null, references = null;
|
||||
if (req.body.reply_to) {
|
||||
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.reply_to, id]);
|
||||
if (orig && orig.message_id) {
|
||||
inReplyTo = '<' + orig.message_id + '>';
|
||||
references = ((orig.email_references ? orig.email_references + ' ' : '') + inReplyTo).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const info = await mailer.sendMail({
|
||||
to, subject, text: body, attachments,
|
||||
inReplyTo, references,
|
||||
});
|
||||
const mid = String(info.messageId || '').replace(/[<>]/g, '');
|
||||
|
||||
await dbRun(
|
||||
`INSERT INTO emails (bewerbung_id, direction, message_id, in_reply_to, email_references,
|
||||
from_addr, to_addr, subject, body_text, attachments_json, seen, email_date)
|
||||
VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
||||
[id, mid, inReplyTo ? inReplyTo.replace(/[<>]/g, '') : null, references ? references.replace(/[<>]/g, '') : null,
|
||||
mailer.fromAddress(), to, subject, body, JSON.stringify(attNames), new Date().toISOString()]
|
||||
);
|
||||
await dbRun('UPDATE bewerbungen SET email_empfaenger = ? WHERE id = ?', [to, id]);
|
||||
|
||||
res.redirect(back('?mailok=' + encodeURIComponent('E-Mail an ' + to + ' gesendet.') + '#korrespondenz'));
|
||||
} catch (error) {
|
||||
console.error('Error sending e-mail:', error);
|
||||
res.redirect(back('?mailerror=' + encodeURIComponent('Versand fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')) + '#korrespondenz'));
|
||||
}
|
||||
});
|
||||
|
||||
// AI-draft a reply to a received e-mail. Returns JSON {betreff, text} that the
|
||||
// frontend drops into the reply form for the user to edit before sending.
|
||||
app.post('/bewerbung/:id/email/ai-reply', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const orig = await dbGet('SELECT * FROM emails WHERE id = ? AND bewerbung_id = ?', [req.body.email_id, id]);
|
||||
if (!orig) return res.status(404).json({ error: 'Nachricht nicht gefunden' });
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
|
||||
const draft = await generateEmailReply({
|
||||
incoming: { from: orig.from_addr, subject: orig.subject, text: orig.body_text },
|
||||
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
|
||||
settings,
|
||||
hinweise: String(req.body.hinweise || ''),
|
||||
});
|
||||
res.json(draft);
|
||||
} catch (error) {
|
||||
console.error('Error drafting AI reply:', error);
|
||||
res.status(500).json({ error: error.message || 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually trigger an IMAP fetch of new replies, then return to the referring page.
|
||||
app.post('/email/fetch', async (req, res) => {
|
||||
const back = req.body.back || req.get('referer') || '/';
|
||||
try {
|
||||
await pollInbox();
|
||||
} catch (e) { /* errors are logged inside pollInbox */ }
|
||||
res.redirect(back);
|
||||
});
|
||||
|
||||
// Download an attachment that arrived with a received e-mail.
|
||||
app.get('/email-anhaenge/:id/download', async (req, res) => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM email_anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (!a) return res.status(404).send('Anhang nicht gefunden');
|
||||
const p = path.join(emailAnhaengeDir, a.pfad);
|
||||
if (!fs.existsSync(p)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(p, a.name || a.pfad);
|
||||
} catch (error) {
|
||||
console.error('Error downloading e-mail attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Add a timeline entry (status change with date + comment)
|
||||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||||
try {
|
||||
@@ -1111,6 +1392,18 @@ initializeDatabase().then(() => {
|
||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// E-Mail: verify SMTP on startup and poll the IMAP inbox for replies.
|
||||
if (mailer.isConfigured()) {
|
||||
mailer.verify()
|
||||
.then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`))
|
||||
.catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message));
|
||||
const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000);
|
||||
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
|
||||
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
|
||||
} else {
|
||||
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
|
||||
}
|
||||
|
||||
// Handle 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).send('Seite nicht gefunden');
|
||||
|
||||
@@ -262,6 +262,142 @@
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<!-- E-Mail-Korrespondenz -->
|
||||
<section id="korrespondenz" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">E-Mail-Korrespondenz</h2>
|
||||
<% if (mailConfigured) { %>
|
||||
<form action="/email/fetch" method="POST" class="shrink-0">
|
||||
<input type="hidden" name="back" value="/bewerbung/<%= application.id %>#korrespondenz">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||
Postfach abrufen
|
||||
</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (!mailConfigured) { %>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 px-4 py-3">
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>
|
||||
<span class="text-sm text-yellow-800 dark:text-yellow-200">E-Mail ist nicht konfiguriert. Bitte <code>MAIL_HOST</code>, <code>MAIL_USER</code> und <code>MAIL_PASSWORD</code> in der <code>.env</code> setzen.</span>
|
||||
</div>
|
||||
<% } else { %>
|
||||
|
||||
<% if (mailError) { %>
|
||||
<div class="rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 mb-4 text-sm text-red-800 dark:text-red-200"><%= mailError %></div>
|
||||
<% } %>
|
||||
<% if (mailOk) { %>
|
||||
<div class="rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-3 mb-4 text-sm text-green-800 dark:text-green-200"><%= mailOk %></div>
|
||||
<% } %>
|
||||
|
||||
<!-- Conversation thread -->
|
||||
<% if (emails && emails.length) { %>
|
||||
<ul class="space-y-3 mb-6">
|
||||
<% emails.forEach(function(e){ var out = e.direction === 'out'; %>
|
||||
<li class="rounded-lg border <%= out ? 'border-blue-200 dark:border-blue-800 bg-blue-50/60 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %> px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= out ? 'bg-blue-600 text-white' : 'bg-gray-600 text-white' %>"><%= out ? 'Gesendet' : 'Empfangen' %></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">
|
||||
<%= out ? ('An: ' + (e.to_addr || '')) : ('Von: ' + (e.from_addr || '')) %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= e.body_text || '' %></p>
|
||||
|
||||
<% if (e.anhaenge && e.anhaenge.length) { %>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<% e.anhaenge.forEach(function(a){ %>
|
||||
<a href="/email-anhaenge/<%= a.id %>/download" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path></svg>
|
||||
<%= a.name || 'Anhang' %>
|
||||
</a>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (!out) { %>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="reply-toggle text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline" data-target="reply-<%= e.id %>">Antworten</button>
|
||||
</div>
|
||||
<form action="/bewerbung/<%= application.id %>/email/send" method="POST" id="reply-<%= e.id %>" class="hidden mt-3 border-t border-gray-200 dark:border-gray-600 pt-3">
|
||||
<input type="hidden" name="reply_to" value="<%= e.id %>">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">An</label>
|
||||
<input type="email" name="to" required value="<%= e.from_addr_clean || '' %>"
|
||||
class="w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||
<input type="text" name="subject" value="Re: <%= e.subject || '' %>"
|
||||
class="reply-subject w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label>
|
||||
<button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
<span class="ai-reply-label">KI-Antwort generieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="body" rows="8" required
|
||||
class="reply-body w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"></textarea>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">Antwort senden</button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<% } else { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Noch keine E-Mails. Sende die Bewerbung unten oder rufe das Postfach ab.</p>
|
||||
<% } %>
|
||||
|
||||
<!-- Compose / send the application -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-white mb-1">Bewerbung per E-Mail senden</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Versand über <code><%= mailFrom %></code>. Empfängeradresse eingeben, Betreff und Text prüfen,
|
||||
Anhänge auswählen und senden.
|
||||
</p>
|
||||
<form action="/bewerbung/<%= application.id %>/email/send" method="POST">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Empfänger (E-Mail)</label>
|
||||
<input type="email" name="to" required value="<%= application.email_empfaenger || '' %>"
|
||||
placeholder="personal@unternehmen.de"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
|
||||
<input type="text" name="subject" value="<%= application.email_betreff || ('Bewerbung als ' + (application.stelle || '')) %>"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nachricht</label>
|
||||
<textarea name="body" rows="10"
|
||||
class="w-full px-3 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"><%= application.email_anschreiben || '' %></textarea>
|
||||
|
||||
<% if (anhaenge && anhaenge.length) { %>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Anhänge</p>
|
||||
<div class="space-y-1 mb-3">
|
||||
<% anhaenge.forEach(function(a){ %>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input type="checkbox" name="anhang" value="<%= a.id %>" checked
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
|
||||
<%= a.name || a.dateiname %> <span class="text-xs text-gray-400">(<%= a.dateiname %>)</span>
|
||||
</label>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p class="text-xs text-gray-400 mb-3">Keine generierten Anhänge vorhanden - erst Unterlagen generieren.</p>
|
||||
<% } %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
|
||||
Bewerbung senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<!-- Status timeline -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
|
||||
@@ -397,6 +533,44 @@
|
||||
} catch (e) { /* clipboard blocked — ignore */ }
|
||||
});
|
||||
|
||||
// Toggle inline reply forms under received e-mails.
|
||||
document.querySelectorAll('.reply-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const form = document.getElementById(btn.dataset.target);
|
||||
if (form) form.classList.toggle('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Generate an AI draft reply and drop it into the reply form.
|
||||
document.querySelectorAll('.ai-reply-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const form = document.getElementById(btn.dataset.target);
|
||||
if (!form) return;
|
||||
const label = btn.querySelector('.ai-reply-label');
|
||||
const prev = label ? label.textContent : '';
|
||||
if (label) label.textContent = 'Generiere …';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_id: btn.dataset.emailId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler');
|
||||
const body = form.querySelector('.reply-body');
|
||||
const subject = form.querySelector('.reply-subject');
|
||||
if (body && data.text) body.value = data.text;
|
||||
if (subject && data.betreff) subject.value = data.betreff;
|
||||
} catch (e) {
|
||||
alert('KI-Antwort fehlgeschlagen: ' + e.message);
|
||||
} finally {
|
||||
if (label) label.textContent = prev;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// While documents are being generated, poll for completion and
|
||||
// reload the page once the status changes.
|
||||
const unterlagen = document.getElementById('unterlagen');
|
||||
|
||||
Reference in New Issue
Block a user