feat: SMTP-Stack mit konfigurierbaren Mailvorlagen
Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung, Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus Umgebungsvariablen. Jetzt: - SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host, Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert, Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach @kc/platform/mail, damit API und Worker sie gemeinsam nutzen. - Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition" (Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand von Beispieldaten. Unbekannte Platzhalter werden beim Speichern abgelehnt. - Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde), Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel- Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese laufen über die Jobqueue (mail.template), analog zu discord.notify. - Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter Einstellungen > E-Mail. - Migration 022, Rechte email.read (Admin+) / email.write (Superadmin). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b112647ff1
commit
a68b4cf6ec
18 changed files with 385 additions and 42 deletions
|
|
@ -23,7 +23,6 @@
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
"fastify": "^5.12.5",
|
"fastify": "^5.12.5",
|
||||||
"mysql2": "^3.24.4",
|
"mysql2": "^3.24.4",
|
||||||
"nodemailer": "^10.0.10",
|
|
||||||
"otpauth": "^9.5.2",
|
"otpauth": "^9.5.2",
|
||||||
"pdfkit": "^0.20.2",
|
"pdfkit": "^0.20.2",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
|
|
@ -31,7 +30,6 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kc/connector-mock": "workspace:*",
|
"@kc/connector-mock": "workspace:*",
|
||||||
"@types/node": "^26.6.3",
|
"@types/node": "^26.6.3",
|
||||||
"@types/nodemailer": "^8.0.2",
|
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
"tsx": "^4.23.15",
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { PoolConnection } from 'mysql2/promise';
|
||||||
import { run } from './db.js';
|
import { run } from './db.js';
|
||||||
import { randomToken, sha256 } from './crypto.js';
|
import { randomToken, sha256 } from './crypto.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { sendMail } from './mail.js';
|
import { sendTemplateMail } from './mail.js';
|
||||||
|
|
||||||
export const INVITE_DAYS = 7;
|
export const INVITE_DAYS = 7;
|
||||||
|
|
||||||
|
|
@ -23,5 +23,5 @@ export const inviteLink = (token: string): string => `${config.baseUrl}/einladun
|
||||||
export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`;
|
export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
export async function mailInvite(email: string, name: string, token: string): Promise<string> {
|
export async function mailInvite(email: string, name: string, token: string): Promise<string> {
|
||||||
return sendMail(email, 'invite', 'Ihr Zugang zum Kundencenter', `Hallo ${name},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest (${INVITE_DAYS} Tage gültig):\n${inviteLink(token)}\n`);
|
return sendTemplateMail(email, 'invite', { name, inviteLink: inviteLink(token), validDays: String(INVITE_DAYS) });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1 @@
|
||||||
import nodemailer from 'nodemailer';
|
export * from '@kc/platform/mail';
|
||||||
import { config } from './config.js';
|
|
||||||
import { run } from './db.js';
|
|
||||||
|
|
||||||
const transport = config.smtp
|
|
||||||
? nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
/** Versand mit Protokoll. Ohne SMTP wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
|
|
||||||
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
|
|
||||||
if (!transport || !config.smtp) {
|
|
||||||
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
|
|
||||||
return 'not_configured';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await transport.sendMail({ from: config.smtp.from, to, subject, text });
|
|
||||||
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
|
|
||||||
return 'sent';
|
|
||||||
} catch (e) {
|
|
||||||
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
|
|
||||||
return 'failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../c
|
||||||
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
|
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
|
||||||
import { can, staffPermissions } from '../../core/policy.js';
|
import { can, staffPermissions } from '../../core/policy.js';
|
||||||
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
|
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
|
||||||
import { sendMail } from '../../core/mail.js';
|
import { sendTemplateMail } from '../../core/mail.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
|
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
|
||||||
|
|
@ -162,7 +162,7 @@ export const identityModule: KcModule = {
|
||||||
const u = await one('SELECT id, name FROM users WHERE email = ? AND status = \'active\'', [e]);
|
const u = await one('SELECT id, name FROM users WHERE email = ? AND status = \'active\'', [e]);
|
||||||
if (u) {
|
if (u) {
|
||||||
const t = await createToken(undefined, u.id, 'password_reset');
|
const t = await createToken(undefined, u.id, 'password_reset');
|
||||||
await sendMail(e, 'password_reset', 'Passwort zurücksetzen', `Hallo ${u.name},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen (1 Stunde gültig):\n${resetLink(t)}\n\nWenn Sie das nicht angefordert haben, ignorieren Sie diese E-Mail.\n`);
|
await sendTemplateMail(e, 'password_reset', { name: u.name, resetLink: resetLink(t) });
|
||||||
await audit({ actorType: 'anonymous', action: 'auth.password.forgot', resourceType: 'user', resourceId: u.id, correlationId: req.correlationId, ip: clientIp(req) });
|
await audit({ actorType: 'anonymous', action: 'auth.password.forgot', resourceType: 'user', resourceId: u.id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||||
}
|
}
|
||||||
return { status: 'accepted' }; // immer gleiche Antwort
|
return { status: 'accepted' }; // immer gleiche Antwort
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ticketsModule } from './tickets/index.js';
|
||||||
import { invoicesModule } from './invoices/index.js';
|
import { invoicesModule } from './invoices/index.js';
|
||||||
import { ordersModule } from './orders/index.js';
|
import { ordersModule } from './orders/index.js';
|
||||||
import { backupModule } from './backup/index.js';
|
import { backupModule } from './backup/index.js';
|
||||||
|
import { mailModule } from './mail/index.js';
|
||||||
|
|
||||||
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
||||||
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule];
|
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule];
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ async function loadInvoice(id: string) {
|
||||||
return { v, items };
|
return { v, items };
|
||||||
}
|
}
|
||||||
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
||||||
|
const mailTo = (to: string, template: string, vars: Record<string, string>, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId });
|
||||||
|
const deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE');
|
||||||
|
const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||||
|
|
||||||
export const invoicesModule: KcModule = {
|
export const invoicesModule: KcModule = {
|
||||||
name: 'invoices',
|
name: 'invoices',
|
||||||
|
|
@ -201,7 +204,7 @@ export const invoicesModule: KcModule = {
|
||||||
const items = await query('SELECT 1 AS x FROM invoice_items WHERE invoice_id = ?', [id]);
|
const items = await query('SELECT 1 AS x FROM invoice_items WHERE invoice_id = ?', [id]);
|
||||||
if (items.length === 0) throw badRequest('Eine Rechnung ohne Positionen kann nicht ausgestellt werden.', 'NO_ITEMS');
|
if (items.length === 0) throw badRequest('Eine Rechnung ohne Positionen kann nicht ausgestellt werden.', 'NO_ITEMS');
|
||||||
const s = await settings(); if (!complete(s)) throw badRequest('Die Firmenstammdaten sind unvollständig (Name, Anschrift, Steuernummer/USt-IdNr.). Bitte unter Einstellungen ergänzen, bevor Rechnungen ausgestellt werden.', 'COMPANY_SETTINGS_INCOMPLETE');
|
const s = await settings(); if (!complete(s)) throw badRequest('Die Firmenstammdaten sind unvollständig (Name, Anschrift, Steuernummer/USt-IdNr.). Bitte unter Einstellungen ergänzen, bevor Rechnungen ausgestellt werden.', 'COMPANY_SETTINGS_INCOMPLETE');
|
||||||
const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [v.org_id]);
|
const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id, b.billing_email, b.contact_name FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [v.org_id]);
|
||||||
const due = b.dueDate ?? new Date(Date.now() + s.defaultDueDays * 86400000).toISOString().slice(0, 10);
|
const due = b.dueDate ?? new Date(Date.now() + s.defaultDueDays * 86400000).toISOString().slice(0, 10);
|
||||||
// Absender-/Empfängerdaten werden JETZT eingefroren (nicht mehr live beim PDF-Abruf gelesen) – ändert sich später
|
// Absender-/Empfängerdaten werden JETZT eingefroren (nicht mehr live beim PDF-Abruf gelesen) – ändert sich später
|
||||||
// IBAN, Steuernummer oder die Kundenanschrift, bleibt diese schon ausgestellte Rechnung unverändert, wie es sein muss.
|
// IBAN, Steuernummer oder die Kundenanschrift, bleibt diese schon ausgestellte Rechnung unverändert, wie es sein muss.
|
||||||
|
|
@ -220,6 +223,7 @@ export const invoicesModule: KcModule = {
|
||||||
});
|
});
|
||||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.issue', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number } });
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.issue', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number } });
|
||||||
await notify('invoice.issued', { number, gross: v.total_gross_cents }, `invoice.issued:${id}`, req.correlationId);
|
await notify('invoice.issued', { number, gross: v.total_gross_cents }, `invoice.issued:${id}`, req.correlationId);
|
||||||
|
if (org?.billing_email) await mailTo(org.billing_email, 'invoice_issued', { name: org.contact_name || org.company || org.name, number, amount: deMoney(v.total_gross_cents), dueDate: deDate(due) }, `mail:invoice.issued:${id}`, req.correlationId);
|
||||||
return { ok: true, number };
|
return { ok: true, number };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
97
apps/api/src/modules/mail/index.ts
Normal file
97
apps/api/src/modules/mail/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { one, query, run } from '../../core/db.js';
|
||||||
|
import { audit } from '../../core/audit.js';
|
||||||
|
import { encrypt } from '../../core/crypto.js';
|
||||||
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
|
import { badRequest, notFound } from '../../core/errors.js';
|
||||||
|
import { rl } from '../../core/config.js';
|
||||||
|
import { renderTemplateText, sendMail } from '../../core/mail.js';
|
||||||
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
|
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
|
||||||
|
const settingsView = (s: any) => ({
|
||||||
|
host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, hasPassword: !!s.secrets_enc,
|
||||||
|
fromName: s.from_name, fromEmail: s.from_email, configured: !!s.host && !!s.from_email, updatedAt: s.updated_at,
|
||||||
|
});
|
||||||
|
/** Beispielwerte für die Testvorschau, je Platzhaltername – fällt auf den Platzhalternamen selbst zurück. */
|
||||||
|
const SAMPLE: Record<string, string> = {
|
||||||
|
name: 'Max Mustermann', inviteLink: 'https://kundencenter.example/einladung?token=…', validDays: '7',
|
||||||
|
resetLink: 'https://kundencenter.example/passwort-reset?token=…', resourceName: 'kd100356644 · webhosting.example',
|
||||||
|
username: 'kd100356644', password: 'BeispielPasswort123', number: 'T-42', subject: 'Beispiel-Betreff',
|
||||||
|
amount: '19,90 €', dueDate: '15.10.2026',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mailModule: KcModule = {
|
||||||
|
name: 'mail',
|
||||||
|
permissions: { staff: { admin: ['email.read'], superadmin: ['email.read', 'email.write'] } },
|
||||||
|
register(app: FastifyInstance) {
|
||||||
|
// ---- SMTP-Verbindung (Einstellungen > E-Mail) ----------------------------------------------
|
||||||
|
app.get('/admin/email/settings', async (req) => {
|
||||||
|
requirePermission(req, 'email.read');
|
||||||
|
return settingsView((await one('SELECT * FROM smtp_settings WHERE id = 1'))!);
|
||||||
|
});
|
||||||
|
app.put('/admin/email/settings', async (req) => {
|
||||||
|
const a = requirePermission(req, 'email.write');
|
||||||
|
const b = z.object({
|
||||||
|
host: z.string().trim().max(253).nullable(), port: z.number().int().min(1).max(65535).default(587),
|
||||||
|
secureMode: z.enum(['none', 'starttls', 'tls']).default('starttls'),
|
||||||
|
username: z.string().trim().max(200).nullable(),
|
||||||
|
password: z.string().max(500).nullable().optional(), // undefined = unverändert lassen, null = löschen
|
||||||
|
fromName: z.string().trim().max(150).nullable(), fromEmail: z.string().trim().max(254).nullable(),
|
||||||
|
}).parse(req.body);
|
||||||
|
if (b.fromEmail && !z.string().email().safeParse(b.fromEmail).success) throw badRequest('Absenderadresse ist keine gültige E-Mail-Adresse', 'BAD_FROM_EMAIL');
|
||||||
|
const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'from_name = ?', 'from_email = ?', 'updated_by = ?'];
|
||||||
|
const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.fromName, b.fromEmail, a.user.id];
|
||||||
|
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }
|
||||||
|
await run(`UPDATE smtp_settings SET ${sets.join(', ')} WHERE id = 1`, params);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.settings.update', resourceType: 'smtp_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, password: b.password !== undefined ? (b.password ? '***' : null) : undefined } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
app.post('/admin/email/settings/test', { config: rl(5, '1 minute') }, async (req) => {
|
||||||
|
const a = requirePermission(req, 'email.write');
|
||||||
|
const { to } = z.object({ to: email }).parse(req.body);
|
||||||
|
const status = await sendMail(to, 'test', 'Testmail vom Kundencenter', 'Diese Testmail bestätigt, dass die SMTP-Einstellungen des Kundencenters funktionieren.\n');
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.settings.test', resourceType: 'smtp_settings', resourceId: '1', result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status } });
|
||||||
|
return { status };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Mailvorlagen: Definition (Auslöser + verfügbare Daten) ist fest, Betreff/Text/aktiv sind editierbar ----
|
||||||
|
const templateView = (t: any) => ({ key: t.key, name: t.name, trigger: t.trigger_text, variables: typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json, subject: t.subject, body: t.body, enabled: !!t.enabled, updatedAt: t.updated_at });
|
||||||
|
app.get('/admin/email/templates', async (req) => { requirePermission(req, 'email.read'); return (await query('SELECT * FROM mail_templates ORDER BY name')).map(templateView); });
|
||||||
|
app.get('/admin/email/templates/:key', async (req) => {
|
||||||
|
requirePermission(req, 'email.read'); const { key } = z.object({ key: z.string().max(60) }).parse(req.params);
|
||||||
|
const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]); if (!t) throw notFound();
|
||||||
|
return templateView(t);
|
||||||
|
});
|
||||||
|
app.put('/admin/email/templates/:key', async (req) => {
|
||||||
|
const a = requirePermission(req, 'email.write'); const { key } = z.object({ key: z.string().max(60) }).parse(req.params);
|
||||||
|
const b = z.object({ subject: z.string().trim().min(1).max(300), body: z.string().trim().min(1).max(4000), enabled: z.boolean() }).parse(req.body);
|
||||||
|
const t = await one('SELECT variables_json FROM mail_templates WHERE `key` = ?', [key]); if (!t) throw notFound();
|
||||||
|
const vars: string[] = typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json;
|
||||||
|
const used = new Set([...b.subject.matchAll(/\{\{(\w+)\}\}/g), ...b.body.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]));
|
||||||
|
const unknown = [...used].filter((v) => !vars.includes(v as string));
|
||||||
|
if (unknown.length) throw badRequest(`Unbekannte Platzhalter: ${unknown.map((v) => `{{${v}}}`).join(', ')}. Verfügbar: ${vars.map((v) => `{{${v}}}`).join(', ')}`, 'UNKNOWN_VARIABLE');
|
||||||
|
await run('UPDATE mail_templates SET subject = ?, body = ?, enabled = ?, updated_by = ? WHERE `key` = ?', [b.subject, b.body, b.enabled ? 1 : 0, a.user.id, key]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.template.update', resourceType: 'mail_template', resourceId: key, correlationId: req.correlationId, ip: clientIp(req), after: b });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
app.post('/admin/email/templates/:key/test', { config: rl(5, '1 minute') }, async (req) => {
|
||||||
|
const a = requirePermission(req, 'email.write'); const { key } = z.object({ key: z.string().max(60) }).parse(req.params);
|
||||||
|
const { to } = z.object({ to: email }).parse(req.body);
|
||||||
|
const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]); if (!t) throw notFound();
|
||||||
|
const vars: string[] = typeof t.variables_json === 'string' ? JSON.parse(t.variables_json) : t.variables_json;
|
||||||
|
const sample = Object.fromEntries(vars.map((v) => [v, SAMPLE[v] ?? `[${v}]`]));
|
||||||
|
const status = await sendMail(to, `${key}:test`, renderTemplateText(t.subject, sample), renderTemplateText(t.body, sample));
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.template.test', resourceType: 'mail_template', resourceId: key, result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to, status } });
|
||||||
|
return { status };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Protokoll der letzten Sendeversuche (kein Inhalt, nur Metadaten) --------------------------
|
||||||
|
app.get('/admin/email/log', async (req) => {
|
||||||
|
requirePermission(req, 'email.read');
|
||||||
|
return (await query('SELECT id, to_email, template, status, error, created_at FROM mail_log ORDER BY id DESC LIMIT 100'))
|
||||||
|
.map((r) => ({ id: r.id, to: r.to_email, template: r.template, status: r.status, error: r.error, createdAt: r.created_at }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -10,6 +10,7 @@ import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk';
|
||||||
import { one, query, run } from '../../core/db.js';
|
import { one, query, run } from '../../core/db.js';
|
||||||
import { audit } from '../../core/audit.js';
|
import { audit } from '../../core/audit.js';
|
||||||
import { enqueue } from '../../core/jobs.js';
|
import { enqueue } from '../../core/jobs.js';
|
||||||
|
import { sendTemplateMail } from '../../core/mail.js';
|
||||||
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
||||||
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
|
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
|
||||||
import { can, canInOrg } from '../../core/policy.js';
|
import { can, canInOrg } from '../../core/policy.js';
|
||||||
|
|
@ -179,6 +180,21 @@ export const resourcesModule: KcModule = {
|
||||||
reply.header('cache-control', 'no-store');
|
reply.header('cache-control', 'no-store');
|
||||||
return { items: [{ label: 'Panel-Passwort', value: password }], hideAfterSec: 60 };
|
return { items: [{ label: 'Panel-Passwort', value: password }], hideAfterSec: 60 };
|
||||||
});
|
});
|
||||||
|
app.post('/resources/:id/panel-credentials/send-mail', { config: rl(5, '1 minute') }, async (req) => {
|
||||||
|
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const r = await loadResource(id); if (!r || !access(a, r)) throw notFound();
|
||||||
|
if (!canResetPassword(r, a)) throw forbidden('Zugangsdaten können für diese Ressource nicht versendet werden', 'REVEAL_FORBIDDEN');
|
||||||
|
if (!r.org_id) throw badRequest('Diese Ressource ist keinem Kunden zugeordnet.', 'NOT_ASSIGNED');
|
||||||
|
const c = await one('SELECT secret_enc FROM panel_credentials WHERE resource_id = ?', [id]);
|
||||||
|
if (!c) throw notFound('Es wurde noch kein Passwort vergeben.');
|
||||||
|
const org = await one('SELECT o.name, b.billing_email, b.contact_name FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [r.org_id]);
|
||||||
|
if (!org?.billing_email) throw badRequest('Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.', 'NO_EMAIL');
|
||||||
|
const password = decrypt(c.secret_enc);
|
||||||
|
const details = json<{ details?: { username?: string } }>(r.data_json, {}).details ?? {};
|
||||||
|
const status = await sendTemplateMail(org.billing_email, 'panel_credentials', { name: org.contact_name || org.name, resourceName: r.name, username: details.username ?? '–', password });
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.panel_credentials.send_mail', resourceType: 'resource', resourceId: id, connector: r.connector_key, result: status === 'sent' ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { to: org.billing_email, status } });
|
||||||
|
return { status };
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
|
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
|
||||||
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
|
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS
|
||||||
const messageView = (m: any, atts: any[] = []) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at,
|
const messageView = (m: any, atts: any[] = []) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at,
|
||||||
attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) });
|
attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) });
|
||||||
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
||||||
|
const mailTo = (to: string, template: string, vars: Record<string, string>, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId });
|
||||||
|
|
||||||
/** Meldet neue Nachrichten/Status auf CUSTOMER-sichtbare Zeilen; interne Notizen werden für Kunden ausgefiltert. */
|
/** Meldet neue Nachrichten/Status auf CUSTOMER-sichtbare Zeilen; interne Notizen werden für Kunden ausgefiltert. */
|
||||||
async function loadTicket(id: string, forCustomer: boolean) {
|
async function loadTicket(id: string, forCustomer: boolean) {
|
||||||
|
|
@ -66,7 +67,10 @@ export const ticketsModule: KcModule = {
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } });
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } });
|
||||||
if (!staff) await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
|
if (!staff) {
|
||||||
|
await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId);
|
||||||
|
await mailTo(a.user.email, 'ticket_created', { name: a.user.name, number, subject: b.subject }, `mail:ticket.created:${id}`, req.correlationId);
|
||||||
|
}
|
||||||
return { id, number, messageId };
|
return { id, number, messageId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -111,6 +115,10 @@ export const ticketsModule: KcModule = {
|
||||||
});
|
});
|
||||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } });
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } });
|
||||||
if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId);
|
if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId);
|
||||||
|
if (!b.internalNote && staff) {
|
||||||
|
const creator = await one('SELECT email, name FROM users WHERE id = ?', [t.created_by]);
|
||||||
|
if (creator) await mailTo(creator.email, 'ticket_message', { name: creator.name, number: t.number, subject: t.subject }, `mail:ticket.message:${id}:${Date.now()}`, req.correlationId);
|
||||||
|
}
|
||||||
return { ok: true, messageId };
|
return { ok: true, messageId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
120
apps/web/src/app/(app)/einstellungen/email/page.tsx
Normal file
120
apps/web/src/app/(app)/einstellungen/email/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { Alert, Field, fmt } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Settings { host: string | null; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; hasPassword: boolean; fromName: string | null; fromEmail: string | null; configured: boolean; updatedAt: string }
|
||||||
|
interface Template { key: string; name: string; trigger: string; variables: string[]; subject: string; body: string; enabled: boolean; updatedAt: string }
|
||||||
|
interface LogRow { id: number; to: string; template: string; status: string; error: string | null; createdAt: string }
|
||||||
|
const SECURE_LABEL: Record<string, string> = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' };
|
||||||
|
const STATUS: Record<string, { cls: string; icon: string; label: string }> = {
|
||||||
|
sent: { cls: 'ok', icon: '✓', label: 'Gesendet' }, failed: { cls: 'err', icon: '✕', label: 'Fehlgeschlagen' },
|
||||||
|
not_configured: { cls: 'warn', icon: '▲', label: 'SMTP nicht eingerichtet' }, disabled: { cls: 'lock', icon: '⊘', label: 'Vorlage deaktiviert' }, no_template: { cls: 'err', icon: '✕', label: 'Vorlage fehlt' },
|
||||||
|
};
|
||||||
|
const StatusBadge = ({ v }: { v: string }) => { const m = STATUS[v] ?? { cls: 'info', icon: '○', label: v }; return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>; };
|
||||||
|
|
||||||
|
/** SMTP-Verbindung: Formular + Testmail. Leer gelassenes Passwortfeld lässt ein bereits gespeichertes Passwort unverändert. */
|
||||||
|
function SmtpForm({ s, w, onSaved }: { s: Settings; w: boolean; onSaved: () => void }) {
|
||||||
|
const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
|
||||||
|
const [testTo, setTestTo] = useState(''); const [testBusy, setTestBusy] = useState(false);
|
||||||
|
async function save(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
|
||||||
|
const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
|
||||||
|
const pw = String(f.get('password') ?? '');
|
||||||
|
try {
|
||||||
|
await api('PUT', '/admin/email/settings', { host: v('host'), port: Number(f.get('port')) || 587, secureMode: f.get('secureMode'), username: v('username'), fromName: v('fromName'), fromEmail: v('fromEmail'), ...(pw ? { password: pw } : {}) });
|
||||||
|
setMsg({ k: 'ok', t: 'Gespeichert.' }); (e.currentTarget.elements.namedItem('password') as HTMLInputElement).value = ''; onSaved();
|
||||||
|
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function sendTest() {
|
||||||
|
setTestBusy(true); setMsg(null);
|
||||||
|
try { const r = await api<{ status: string }>('POST', '/admin/email/settings/test', { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}` }); }
|
||||||
|
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
|
||||||
|
}
|
||||||
|
return (<div className="card">
|
||||||
|
<h2>SMTP-Verbindung</h2>
|
||||||
|
{!s.configured && <Alert kind="warn">Noch nicht eingerichtet. Solange Host und Absenderadresse fehlen, werden E-Mails nicht versendet, aber im Protokoll unten als „nicht eingerichtet“ vermerkt.</Alert>}
|
||||||
|
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<div className="cols">
|
||||||
|
<Field id="host" label="SMTP-Server (Host)"><input id="host" name="host" defaultValue={s.host ?? ''} placeholder="smtp.example.de" disabled={!w} /></Field>
|
||||||
|
<Field id="port" label="Port"><input id="port" name="port" type="number" min={1} max={65535} defaultValue={s.port} disabled={!w} /></Field>
|
||||||
|
<Field id="secureMode" label="Verschlüsselung"><select id="secureMode" name="secureMode" defaultValue={s.secureMode} disabled={!w}>{Object.entries(SECURE_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}</select></Field>
|
||||||
|
<Field id="username" label="Benutzername (leer = keine Anmeldung)"><input id="username" name="username" defaultValue={s.username ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="password" label="Passwort" hint={s.hasPassword ? 'Ein Passwort ist gespeichert. Leer lassen, um es unverändert zu lassen.' : 'Noch kein Passwort gespeichert.'}><input id="password" name="password" type="password" autoComplete="new-password" disabled={!w} /></Field>
|
||||||
|
<Field id="fromName" label="Absendername"><input id="fromName" name="fromName" defaultValue={s.fromName ?? ''} placeholder="Kundencenter" disabled={!w} /></Field>
|
||||||
|
<Field id="fromEmail" label="Absenderadresse"><input id="fromEmail" name="fromEmail" type="email" defaultValue={s.fromEmail ?? ''} placeholder="kundencenter@example.de" disabled={!w} /></Field>
|
||||||
|
</div>
|
||||||
|
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button></div>}
|
||||||
|
</form>
|
||||||
|
{w && <div className="row" style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
|
||||||
|
<input aria-label="Test-Empfänger" type="email" placeholder="test@example.de" value={testTo} onChange={(e) => setTestTo(e.target.value)} style={{ maxWidth: 260 }} />
|
||||||
|
<button className="btn" disabled={!testTo || testBusy} onClick={sendTest}>{testBusy ? 'Bitte warten …' : 'Testmail senden'}</button>
|
||||||
|
</div>}
|
||||||
|
<p className="small muted" style={{ marginTop: 8 }}>Zuletzt geändert: {fmt(s.updatedAt)}</p>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Vorlage: feste Definition (Auslöser, verfügbare Daten) + editierbarer Betreff/Text. */
|
||||||
|
function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
|
||||||
|
const [testTo, setTestTo] = useState(''); const [testBusy, setTestBusy] = useState(false);
|
||||||
|
async function save(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
try { await api('PUT', `/admin/email/templates/${t.key}`, { subject: f.get('subject'), body: f.get('body'), enabled: f.get('enabled') === 'on' }); setMsg({ k: 'ok', t: 'Gespeichert.' }); onSaved(); }
|
||||||
|
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function sendTest() {
|
||||||
|
setTestBusy(true); setMsg(null);
|
||||||
|
try { const r = await api<{ status: string }>('POST', `/admin/email/templates/${t.key}/test`, { to: testTo }); setMsg({ k: r.status === 'sent' ? 'ok' : 'err', t: r.status === 'sent' ? `Testmail mit Beispieldaten an ${testTo} gesendet.` : `Status: ${STATUS[r.status]?.label ?? r.status}` }); }
|
||||||
|
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
|
||||||
|
}
|
||||||
|
return (<div className="card">
|
||||||
|
<div className="row between">
|
||||||
|
<div><h3 style={{ margin: 0 }}>{t.name}</h3><p className="small muted" style={{ margin: '4px 0 0' }}>{t.trigger}</p></div>
|
||||||
|
<div className="row">{!t.enabled && <span className="badge lock"><span aria-hidden="true">⊘</span>Deaktiviert</span>}<button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? 'Schließen' : 'Bearbeiten'}</button></div>
|
||||||
|
</div>
|
||||||
|
{open && <div style={{ marginTop: 16 }}>
|
||||||
|
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
|
||||||
|
<p className="small">Verfügbare Daten für diese Vorlage: {t.variables.map((v) => <code key={v} className="mono" style={{ marginRight: 8 }}>{`{{${v}}}`}</code>)}</p>
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<Field id={`subj-${t.key}`} label="Betreff"><input id={`subj-${t.key}`} name="subject" defaultValue={t.subject} disabled={!w} required /></Field>
|
||||||
|
<Field id={`body-${t.key}`} label="Text"><textarea id={`body-${t.key}`} name="body" defaultValue={t.body} disabled={!w} required rows={7} style={{ width: '100%', font: 'inherit', padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', background: 'var(--surface)', color: 'var(--text)' }} /></Field>
|
||||||
|
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={t.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Vorlage aktiv (sonst wird bei diesem Ereignis keine E-Mail versendet)</label>
|
||||||
|
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button></div>}
|
||||||
|
</form>
|
||||||
|
{w && <div className="row" style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||||
|
<input aria-label="Test-Empfänger" type="email" placeholder="test@example.de" value={testTo} onChange={(e) => setTestTo(e.target.value)} style={{ maxWidth: 260 }} />
|
||||||
|
<button className="btn small" disabled={!testTo || testBusy} onClick={sendTest}>{testBusy ? 'Bitte warten …' : 'Testmail mit Beispieldaten senden'}</button>
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailSettings() {
|
||||||
|
const { can } = useSession(); const w = can('email.write');
|
||||||
|
const [s, setS] = useState<Settings | null>(null); const [tpls, setTpls] = useState<Template[] | null>(null); const [log, setLog] = useState<LogRow[] | null>(null); const [err, setErr] = useState('');
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api<Settings>('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e)));
|
||||||
|
api<Template[]>('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e)));
|
||||||
|
api<LogRow[]>('GET', '/admin/email/log').then(setLog).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
return (<>
|
||||||
|
<h2>E-Mail</h2>
|
||||||
|
{err && <Alert kind="err">{err}</Alert>}
|
||||||
|
{!w && <Alert kind="info">Nur Superadministratoren können die SMTP-Verbindung und Mailvorlagen ändern. Sie können hier den aktuellen Stand und das Protokoll einsehen.</Alert>}
|
||||||
|
{s ? <SmtpForm s={s} w={w} onSaved={load} /> : <p className="muted" role="status">Wird geladen …</p>}
|
||||||
|
<h2 style={{ marginTop: 32 }}>Mailvorlagen</h2>
|
||||||
|
<p className="muted small" style={{ marginTop: -8 }}>Jede Vorlage wird bei einem festen Ereignis im Kundencenter verwendet. Betreff und Text können angepasst werden; Platzhalter wie <code className="mono">{'{{name}}'}</code> werden beim Versand durch die tatsächlichen Daten ersetzt. Welche Platzhalter je Vorlage zur Verfügung stehen, steht direkt unter „Bearbeiten“.</p>
|
||||||
|
{tpls === null ? <p className="muted" role="status">Wird geladen …</p> : tpls.map((t) => <TemplateCard key={t.key} t={t} w={w} onSaved={load} />)}
|
||||||
|
<h2 style={{ marginTop: 32 }}>Protokoll</h2>
|
||||||
|
<p className="muted small" style={{ marginTop: -8 }}>Die letzten 100 Sendeversuche. Der Inhalt der E-Mails wird nicht protokolliert.</p>
|
||||||
|
<div className="card"><div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Empfänger</th><th>Vorlage</th><th>Status</th></tr></thead><tbody>
|
||||||
|
{log === null ? <tr><td colSpan={4} className="muted">Wird geladen …</td></tr> : log.length === 0 ? <tr><td colSpan={4} className="muted">Noch keine Einträge.</td></tr> :
|
||||||
|
log.map((l) => <tr key={l.id}><td className="small">{fmt(l.createdAt)}</td><td className="small">{l.to}</td><td className="small">{l.template}</td><td><StatusBadge v={l.status} />{l.error && <div className="small muted">{l.error}</div>}</td></tr>)}
|
||||||
|
</tbody></table></div></div>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
|
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
|
||||||
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
|
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
|
||||||
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
|
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
|
||||||
|
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
|
||||||
].filter((t) => t.show);
|
].filter((t) => t.show);
|
||||||
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||||
return (<>
|
return (<>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export default function Ressource() {
|
||||||
const [confirm, setConfirm] = useState<{ action: string; days?: number } | null>(null); const key = useRef(uuid());
|
const [confirm, setConfirm] = useState<{ action: string; days?: number } | null>(null); const key = useRef(uuid());
|
||||||
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]);
|
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]);
|
||||||
const [creds, setCreds] = useState<PanelCreds | null>(null); const [credsBusy, setCredsBusy] = useState(false); const [credsConfirm, setCredsConfirm] = useState(false);
|
const [creds, setCreds] = useState<PanelCreds | null>(null); const [credsBusy, setCredsBusy] = useState(false); const [credsConfirm, setCredsConfirm] = useState(false);
|
||||||
|
const [mailBusy, setMailBusy] = useState(false);
|
||||||
const load = useCallback(() => Promise.all([
|
const load = useCallback(() => Promise.all([
|
||||||
api<Det>('GET', `/resources/${id}`).then(setD),
|
api<Det>('GET', `/resources/${id}`).then(setD),
|
||||||
api<PanelCreds>('GET', `/resources/${id}/panel-credentials`).then(setCreds).catch(() => undefined),
|
api<PanelCreds>('GET', `/resources/${id}/panel-credentials`).then(setCreds).catch(() => undefined),
|
||||||
|
|
@ -29,6 +30,11 @@ export default function Ressource() {
|
||||||
try { await api('POST', `/resources/${id}/panel-credentials/reset`); setMsg({ k: 'ok', t: 'Wird gesetzt … das kann einen Moment dauern.' }); void load(); }
|
try { await api('POST', `/resources/${id}/panel-credentials/reset`); setMsg({ k: 'ok', t: 'Wird gesetzt … das kann einen Moment dauern.' }); void load(); }
|
||||||
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setCredsBusy(false); }
|
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setCredsBusy(false); }
|
||||||
}
|
}
|
||||||
|
async function mailPanelCredentials() {
|
||||||
|
setMailBusy(true);
|
||||||
|
try { const r = await api<{ status: string }>('POST', `/resources/${id}/panel-credentials/send-mail`); setMsg({ k: r.status === 'sent' ? 'ok' : 'warn', t: r.status === 'sent' ? 'Die Zugangsdaten wurden per E-Mail versendet.' : `Nicht gesendet (Status: ${r.status}).` }); }
|
||||||
|
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setMailBusy(false); }
|
||||||
|
}
|
||||||
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
|
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
|
||||||
// Laufende Aufträge regelmäßig aktualisieren
|
// Laufende Aufträge regelmäßig aktualisieren
|
||||||
const pending = d?.jobs.some((j) => ['scheduled', 'running', 'retrying', 'waiting_external'].includes(j.status));
|
const pending = d?.jobs.some((j) => ['scheduled', 'running', 'retrying', 'waiting_external'].includes(j.status));
|
||||||
|
|
@ -76,6 +82,7 @@ export default function Ressource() {
|
||||||
{creds.set ? (<>
|
{creds.set ? (<>
|
||||||
<SecretField resourceId={d.id} url={`/resources/${d.id}/panel-credentials/reveal`} label="Panel-Passwort" placeholder="••••••••••••••••••" />
|
<SecretField resourceId={d.id} url={`/resources/${d.id}/panel-credentials/reveal`} label="Panel-Passwort" placeholder="••••••••••••••••••" />
|
||||||
<p className="muted small" style={{ marginTop: 8 }}>Zuletzt vergeben: {creds.setAt ? fmt(creds.setAt) : '–'}</p>
|
<p className="muted small" style={{ marginTop: 8 }}>Zuletzt vergeben: {creds.setAt ? fmt(creds.setAt) : '–'}</p>
|
||||||
|
{d.orgId && <button className="btn small" style={{ marginTop: 8 }} disabled={mailBusy} onClick={mailPanelCredentials}>{mailBusy ? 'Bitte warten …' : 'Zugangsdaten per E-Mail senden'}</button>}
|
||||||
</>) : <p className="muted small">Es wurde noch kein Passwort über das Kundencenter vergeben.</p>}
|
</>) : <p className="muted small">Es wurde noch kein Passwort über das Kundencenter vergeben.</p>}
|
||||||
{credsConfirm ? (
|
{credsConfirm ? (
|
||||||
<div role="alertdialog" aria-labelledby="cd-pw" style={{ marginTop: 12 }}><p id="cd-pw"><strong>Neues Passwort setzen?</strong> Das {creds.set ? 'bisherige, über das Kundencenter vergebene' : 'aktuelle'} Passwort wird damit ungültig.</p>
|
<div role="alertdialog" aria-labelledby="cd-pw" style={{ marginTop: 12 }}><p id="cd-pw"><strong>Neues Passwort setzen?</strong> Das {creds.set ? 'bisherige, über das Kundencenter vergebene' : 'aktuelle'} Passwort wird damit ungültig.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { pool } from '@kc/platform/db';
|
import { pool } from '@kc/platform/db';
|
||||||
import { notifyAdmin } from './discord.js';
|
import { notifyAdmin } from './discord.js';
|
||||||
import { JobFailure } from '@kc/platform/jobs';
|
import { JobFailure } from '@kc/platform/jobs';
|
||||||
|
import { sendTemplateMail } from '@kc/platform/mail';
|
||||||
import { executeAction, executeChild, provisionOrder, syncInstance, type ChildPayload, type ExecutePayload } from '@kc/connectors';
|
import { executeAction, executeChild, provisionOrder, syncInstance, type ChildPayload, type ExecutePayload } from '@kc/connectors';
|
||||||
|
|
||||||
interface JobMeta { id: string; correlationId: string; attempt: number; maxAttempts: number }
|
interface JobMeta { id: string; correlationId: string; attempt: number; maxAttempts: number }
|
||||||
|
|
@ -19,6 +20,12 @@ const handlers: Record<string, Handler> = {
|
||||||
const r = await notifyAdmin(text);
|
const r = await notifyAdmin(text);
|
||||||
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
|
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
|
||||||
},
|
},
|
||||||
|
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {
|
||||||
|
const status = await sendTemplateMail(p.to, p.key, p.vars);
|
||||||
|
if (status === 'failed') throw new Error('Mailversand fehlgeschlagen (siehe Protokoll unter Einstellungen > E-Mail)');
|
||||||
|
if (status === 'no_template') throw new Error(`Unbekannte Mailvorlage: ${p.key}`);
|
||||||
|
return status === 'not_configured' ? 'übersprungen: SMTP nicht konfiguriert' : status === 'disabled' ? 'übersprungen: Vorlage deaktiviert' : 'gesendet';
|
||||||
|
},
|
||||||
};
|
};
|
||||||
handlers['connector.sync'] = async (p, j) => {
|
handlers['connector.sync'] = async (p, j) => {
|
||||||
const r = await syncInstance(p.instanceId, j.correlationId);
|
const r = await syncInstance(p.instanceId, j.correlationId);
|
||||||
|
|
|
||||||
54
migrations/022_mail_templates.sql
Normal file
54
migrations/022_mail_templates.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- SMTP-Stack: konfigurierbare Verbindung (statt Umgebungsvariablen) und Mailvorlagen mit dokumentierten Platzhaltern.
|
||||||
|
CREATE TABLE smtp_settings (
|
||||||
|
id TINYINT PRIMARY KEY DEFAULT 1,
|
||||||
|
host VARCHAR(253) NULL,
|
||||||
|
port SMALLINT UNSIGNED NOT NULL DEFAULT 587,
|
||||||
|
secure_mode ENUM('none','starttls','tls') NOT NULL DEFAULT 'starttls',
|
||||||
|
username VARCHAR(200) NULL,
|
||||||
|
secrets_enc TEXT NULL,
|
||||||
|
from_name VARCHAR(150) NULL,
|
||||||
|
from_email VARCHAR(254) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by CHAR(36) NULL REFERENCES users(id),
|
||||||
|
CONSTRAINT chk_smtp_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
INSERT INTO smtp_settings (id) VALUES (1);
|
||||||
|
|
||||||
|
-- Vorlagen je Ereignis ("Definition"): Betreff/Text mit {{platzhalter}}, dokumentierte verfügbare Daten je Vorlage.
|
||||||
|
CREATE TABLE mail_templates (
|
||||||
|
`key` VARCHAR(60) PRIMARY KEY,
|
||||||
|
name VARCHAR(150) NOT NULL,
|
||||||
|
trigger_text VARCHAR(300) NOT NULL,
|
||||||
|
variables_json JSON NOT NULL,
|
||||||
|
subject VARCHAR(300) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by CHAR(36) NULL REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO mail_templates (`key`, name, trigger_text, variables_json, subject, body) VALUES
|
||||||
|
('invite', 'Zugang eingerichtet', 'Wenn für einen Kunden ein neuer Zugang angelegt wird.',
|
||||||
|
JSON_ARRAY('name','inviteLink','validDays'),
|
||||||
|
'Ihr Zugang zum Kundencenter',
|
||||||
|
'Hallo {{name}},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest ({{validDays}} Tage gültig):\n{{inviteLink}}\n'),
|
||||||
|
('password_reset', 'Passwort zurücksetzen', 'Wenn ein Passwort-Reset angefordert wird (Kunde oder Personal).',
|
||||||
|
JSON_ARRAY('name','resetLink'),
|
||||||
|
'Passwort zurücksetzen',
|
||||||
|
'Hallo {{name}},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen (1 Stunde gültig):\n{{resetLink}}\n\nWenn Sie das nicht angefordert haben, ignorieren Sie diese E-Mail.\n'),
|
||||||
|
('panel_credentials', 'Neue Panel-Zugangsdaten', 'Wenn Personal nach dem Neuvergeben eines Panel-Passworts auf „Per E-Mail senden“ klickt.',
|
||||||
|
JSON_ARRAY('name','resourceName','username','password'),
|
||||||
|
'Ihre neuen Zugangsdaten für {{resourceName}}',
|
||||||
|
'Hallo {{name}},\n\nfür {{resourceName}} wurden neue Zugangsdaten vergeben:\n\nBenutzername: {{username}}\nPasswort: {{password}}\n\nBitte bewahren Sie die Zugangsdaten sicher auf und ändern Sie das Passwort nach der ersten Anmeldung, falls möglich.\n'),
|
||||||
|
('ticket_created', 'Ticket erstellt (Bestätigung an Kunde)', 'Wenn ein Kunde selbst ein neues Ticket erstellt.',
|
||||||
|
JSON_ARRAY('name','number','subject'),
|
||||||
|
'Ticket {{number}} wurde erstellt',
|
||||||
|
'Hallo {{name}},\n\nIhr Ticket „{{subject}}“ ({{number}}) wurde bei uns erstellt. Wir melden uns, sobald es eine Antwort gibt.\n'),
|
||||||
|
('ticket_message', 'Antwort auf Ticket (an Kunde)', 'Wenn Personal auf ein Kunden-Ticket antwortet (keine interne Notiz).',
|
||||||
|
JSON_ARRAY('name','number','subject'),
|
||||||
|
'Neue Antwort zu Ticket {{number}}',
|
||||||
|
'Hallo {{name}},\n\nes gibt eine neue Antwort zu Ihrem Ticket „{{subject}}“ ({{number}}). Bitte im Kundencenter ansehen und bei Bedarf antworten.\n'),
|
||||||
|
('invoice_issued', 'Rechnung erstellt', 'Wenn eine Rechnung ausgestellt wird.',
|
||||||
|
JSON_ARRAY('name','number','amount','dueDate'),
|
||||||
|
'Ihre Rechnung {{number}}',
|
||||||
|
'Hallo {{name}},\n\nIhre Rechnung {{number}} über {{amount}} wurde erstellt, fällig am {{dueDate}}. Sie können sie im Kundencenter einsehen und als PDF herunterladen.\n');
|
||||||
|
|
@ -15,10 +15,12 @@
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "^3.24.4"
|
"mysql2": "^3.24.4",
|
||||||
|
"nodemailer": "^10.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.6.3",
|
"@types/node": "^26.6.3",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.2"
|
"vitest": "^5.0.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,5 @@ export const config = {
|
||||||
user: req('DB_USER'),
|
user: req('DB_USER'),
|
||||||
password: req('DB_PASSWORD'),
|
password: req('DB_PASSWORD'),
|
||||||
},
|
},
|
||||||
smtp: process.env.SMTP_HOST
|
|
||||||
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');
|
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');
|
||||||
|
|
|
||||||
53
packages/platform/src/mail.ts
Normal file
53
packages/platform/src/mail.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { one, run } from './db.js';
|
||||||
|
import { decrypt } from './crypto.js';
|
||||||
|
|
||||||
|
export interface SmtpConfig { host: string; port: number; secureMode: 'none' | 'starttls' | 'tls'; username: string | null; fromName: string | null; fromEmail: string }
|
||||||
|
|
||||||
|
/** Lädt die SMTP-Verbindung aus der Datenbank (Einstellungen > E-Mail). Kein Host/Absender gesetzt = nicht eingerichtet. */
|
||||||
|
export async function loadSmtpConfig(): Promise<SmtpConfig | null> {
|
||||||
|
const s = await one('SELECT * FROM smtp_settings WHERE id = 1');
|
||||||
|
if (!s?.host || !s?.from_email) return null;
|
||||||
|
return { host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, fromName: s.from_name, fromEmail: s.from_email };
|
||||||
|
}
|
||||||
|
async function smtpPassword(): Promise<string | null> {
|
||||||
|
const s = await one('SELECT secrets_enc FROM smtp_settings WHERE id = 1');
|
||||||
|
return s?.secrets_enc ? (JSON.parse(decrypt(s.secrets_enc)).password ?? null) : null;
|
||||||
|
}
|
||||||
|
async function transportFor(cfg: SmtpConfig) {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: cfg.host, port: cfg.port, secure: cfg.secureMode === 'tls', requireTLS: cfg.secureMode === 'starttls',
|
||||||
|
auth: cfg.username ? { user: cfg.username, pass: (await smtpPassword()) ?? '' } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versand mit Protokoll. Ohne SMTP-Konfiguration wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
|
||||||
|
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
|
||||||
|
const cfg = await loadSmtpConfig();
|
||||||
|
if (!cfg) {
|
||||||
|
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
|
||||||
|
return 'not_configured';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const transport = await transportFor(cfg);
|
||||||
|
await transport.sendMail({ from: cfg.fromName ? `"${cfg.fromName.replace(/"/g, "'")}" <${cfg.fromEmail}>` : cfg.fromEmail, to, subject, text });
|
||||||
|
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
|
||||||
|
return 'sent';
|
||||||
|
} catch (e) {
|
||||||
|
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt {{platzhalter}} durch die übergebenen Werte; unbekannte Platzhalter bleiben unverändert stehen (fällt in der Vorschau auf). */
|
||||||
|
export function renderTemplateText(tpl: string, vars: Record<string, string>): string {
|
||||||
|
return tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k]! : m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sendet anhand einer in der Datenbank hinterlegten Vorlage (Einstellungen > E-Mail > Vorlagen). */
|
||||||
|
export async function sendTemplateMail(to: string, key: string, vars: Record<string, string>): Promise<'sent' | 'failed' | 'not_configured' | 'disabled' | 'no_template'> {
|
||||||
|
const t = await one('SELECT * FROM mail_templates WHERE `key` = ?', [key]);
|
||||||
|
if (!t) return 'no_template';
|
||||||
|
if (!t.enabled) return 'disabled';
|
||||||
|
return sendMail(to, key, renderTemplateText(t.subject, vars), renderTemplateText(t.body, vars));
|
||||||
|
}
|
||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -40,9 +40,6 @@ importers:
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: ^3.24.4
|
specifier: ^3.24.4
|
||||||
version: 3.24.4(@types/node@26.6.3)
|
version: 3.24.4(@types/node@26.6.3)
|
||||||
nodemailer:
|
|
||||||
specifier: ^10.0.10
|
|
||||||
version: 10.0.10
|
|
||||||
otpauth:
|
otpauth:
|
||||||
specifier: ^9.5.2
|
specifier: ^9.5.2
|
||||||
version: 9.5.2
|
version: 9.5.2
|
||||||
|
|
@ -59,9 +56,6 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.6.3
|
specifier: ^26.6.3
|
||||||
version: 26.6.3
|
version: 26.6.3
|
||||||
'@types/nodemailer':
|
|
||||||
specifier: ^8.0.2
|
|
||||||
version: 8.0.2
|
|
||||||
'@types/pdfkit':
|
'@types/pdfkit':
|
||||||
specifier: ^0.17.6
|
specifier: ^0.17.6
|
||||||
version: 0.17.6
|
version: 0.17.6
|
||||||
|
|
@ -230,10 +224,16 @@ importers:
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: ^3.24.4
|
specifier: ^3.24.4
|
||||||
version: 3.24.4(@types/node@26.6.3)
|
version: 3.24.4(@types/node@26.6.3)
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^10.0.10
|
||||||
|
version: 10.0.10
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.6.3
|
specifier: ^26.6.3
|
||||||
version: 26.6.3
|
version: 26.6.3
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.2
|
||||||
|
version: 8.0.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue