Add e-mail system: send applications, receive & reply to responses

Send the complete application (body + generated attachments) to a
user-entered address via authenticated SMTP submission through the
account's own server, so it applies DKIM and uses its reputable IP/PTR —
required for deliverability here (domain publishes SPF -all, DMARC
p=reject). From/Return-Path stay aligned on the sending domain.

Reply e-mails are polled over IMAP, parsed, stored and matched to the
right application (via In-Reply-To/References, then sender address);
their attachments are saved and downloadable. The detail page gains a
correspondence thread with compose, threaded reply, and an AI-drafted
reply the user can edit before sending.

New: lib/mailer.js (nodemailer + imapflow + mailparser), generateEmailReply
in lib/documents.js, emails/email_anhaenge/app_state tables, background
poller + manual fetch. Credentials come from MAIL_* env vars (.env, not
committed / not in the image).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:59:32 +02:00
co-authored by Claude Opus 4.8
parent 3c553cadf5
commit 0b44f61410
7 changed files with 1289 additions and 4 deletions
+15
View File
@@ -9,3 +9,18 @@ OLLAMA_API_KEY=
# Optional anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434) # Optional anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
# OLLAMA_HOST=https://ollama.com # 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
+93
View File
@@ -1013,8 +1013,101 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
return { documents, email: data.email || { betreff: '', text: '' } }; 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 = { module.exports = {
generateApplicationDocuments, generateApplicationDocuments,
generateEmailReply,
generateTailoredTexts, generateTailoredTexts,
renderLebenslaufPdf, renderLebenslaufPdf,
renderAnschreibenPdf, renderAnschreibenPdf,
+179
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
module.exports = {
isConfigured,
verify,
sendMail,
fetchSince,
fromField,
fromAddress,
textToHtml,
config: cfg,
};
+531 -3
View File
@@ -11,9 +11,12 @@
"dependencies": { "dependencies": {
"ejs": "^3.1.9", "ejs": "^3.1.9",
"express": "^4.18.2", "express": "^4.18.2",
"imapflow": "^1.4.3",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"mailparser": "^3.9.12",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
@@ -62,6 +65,28 @@
"node": ">=10" "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": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -79,6 +104,17 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -240,6 +276,15 @@
"node": ">= 4.5.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -670,6 +715,15 @@
"node": ">=4.0.0" "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": { "node_modules/delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -705,6 +759,47 @@
"node": ">=8" "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": { "node_modules/dompurify": {
"version": "2.5.9", "version": "2.5.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
@@ -712,6 +807,20 @@
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optional": true "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -773,6 +882,15 @@
"iconv-lite": "^0.6.2" "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": { "node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -795,6 +913,18 @@
"once": "^1.4.0" "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": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -1214,6 +1344,34 @@
"node": ">= 0.4" "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": { "node_modules/html2canvas": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
@@ -1228,6 +1386,37 @@
"node": ">=8.0.0" "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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1383,6 +1572,48 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/imurmurhash": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "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", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
} }
@@ -1567,6 +1797,74 @@
"jspdf": "^2.5.1" "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": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1580,6 +1878,49 @@
"node": ">=10" "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": { "node_modules/make-fetch-happen": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
@@ -1906,6 +2247,15 @@
"node": ">= 10.12.0" "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": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -2054,6 +2404,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2091,6 +2450,19 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2116,6 +2488,15 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "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": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -2142,6 +2523,43 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2169,6 +2587,22 @@
"node": ">=10" "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": { "node_modules/promise-inflight": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -2220,6 +2654,15 @@
"once": "^1.3.1" "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": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -2235,6 +2678,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/raf": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -2311,6 +2760,15 @@
"node": ">=8.10.0" "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": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@@ -2375,12 +2833,33 @@
], ],
"license": "MIT" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "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", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 6.0.0", "node": ">= 6.0.0",
"npm": ">= 3.0.0" "npm": ">= 3.0.0"
@@ -2604,7 +3082,6 @@
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ip-address": "^10.1.1", "ip-address": "^10.1.1",
"smart-buffer": "^4.2.0" "smart-buffer": "^4.2.0"
@@ -2654,6 +3131,24 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/sqlite3": {
"version": "5.1.7", "version": "5.1.7",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
@@ -2858,6 +3353,33 @@
"utrie": "^1.0.2" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "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==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT" "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": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+3
View File
@@ -13,9 +13,12 @@
"dependencies": { "dependencies": {
"ejs": "^3.1.9", "ejs": "^3.1.9",
"express": "^4.18.2", "express": "^4.18.2",
"imapflow": "^1.4.3",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"mailparser": "^3.9.12",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
+294 -1
View File
@@ -27,7 +27,8 @@ const multer = require('multer');
} }
})(); })();
const { generateApplicationDocuments } = require('./lib/documents'); const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const mailer = require('./lib/mailer');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -76,6 +77,12 @@ if (!fs.existsSync(anhaengeDir)) {
fs.mkdirSync(anhaengeDir, { recursive: true }); 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 // Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
// and that are sent along with every generated application. // and that are sent along with every generated application.
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge'); 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 // Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) { async function syncCurrentStatus(bewerbungId) {
const latest = await dbGet( const latest = await dbGet(
@@ -399,6 +495,50 @@ function initializeDatabase() {
`, (err) => { `, (err) => {
if (err) return reject(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(` db.run(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -746,10 +886,38 @@ initializeDatabase().then(() => {
); );
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); 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', { res.render('bewerbung', {
application, application,
verlauf, verlauf,
anhaenge, 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, basisCount: basisCountRow ? basisCountRow.count : 0,
artOptions: ART_OPTIONS, artOptions: ART_OPTIONS,
statusOptions: STATUS_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) // Add a timeline entry (status change with date + comment)
app.post('/bewerbung/:id/verlauf', async (req, res) => { app.post('/bewerbung/:id/verlauf', async (req, res) => {
try { try {
@@ -1111,6 +1392,18 @@ initializeDatabase().then(() => {
console.log(`Server läuft auf http://localhost:${PORT}`); console.log(`Server läuft auf http://localhost:${PORT}`);
}); });
// E-Mail: verify SMTP on startup and poll the IMAP inbox for replies.
if (mailer.isConfigured()) {
mailer.verify()
.then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`))
.catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message));
const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000);
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
} else {
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
}
// Handle 404 // Handle 404
app.use((req, res) => { app.use((req, res) => {
res.status(404).send('Seite nicht gefunden'); res.status(404).send('Seite nicht gefunden');
+174
View File
@@ -262,6 +262,142 @@
</section> </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 --> <!-- Status timeline -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <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> <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 */ } } 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 // While documents are being generated, poll for completion and
// reload the page once the status changes. // reload the page once the status changes.
const unterlagen = document.getElementById('unterlagen'); const unterlagen = document.getElementById('unterlagen');