diff --git a/apps/api/package.json b/apps/api/package.json index fec63b6..1f3ea95 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,7 +23,6 @@ "@node-rs/argon2": "^2.2.1", "fastify": "^5.12.5", "mysql2": "^3.24.4", - "nodemailer": "^10.0.10", "otpauth": "^9.5.2", "pdfkit": "^0.20.2", "zod": "^4.6.5" @@ -31,7 +30,6 @@ "devDependencies": { "@kc/connector-mock": "workspace:*", "@types/node": "^26.6.3", - "@types/nodemailer": "^8.0.2", "@types/pdfkit": "^0.17.6", "tsx": "^4.23.15", "typescript": "^7.0.2", diff --git a/apps/api/src/core/accounts.ts b/apps/api/src/core/accounts.ts index 4f999c2..08731d7 100644 --- a/apps/api/src/core/accounts.ts +++ b/apps/api/src/core/accounts.ts @@ -3,7 +3,7 @@ import type { PoolConnection } from 'mysql2/promise'; import { run } from './db.js'; import { randomToken, sha256 } from './crypto.js'; import { config } from './config.js'; -import { sendMail } from './mail.js'; +import { sendTemplateMail } from './mail.js'; 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 async function mailInvite(email: string, name: string, token: string): Promise { - 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) }); } diff --git a/apps/api/src/core/mail.ts b/apps/api/src/core/mail.ts index 38927f7..b2dba38 100644 --- a/apps/api/src/core/mail.ts +++ b/apps/api/src/core/mail.ts @@ -1,23 +1 @@ -import nodemailer from 'nodemailer'; -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'; - } -} +export * from '@kc/platform/mail'; diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts index fa7529f..5a91382 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -11,7 +11,7 @@ import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../c import { decrypt, encrypt, sha256 } from '../../core/crypto.js'; import { can, staffPermissions } from '../../core/policy.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'; 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]); if (u) { 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) }); } return { status: 'accepted' }; // immer gleiche Antwort diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 1cd3618..9533bd5 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -11,6 +11,7 @@ import { ticketsModule } from './tickets/index.js'; import { invoicesModule } from './invoices/index.js'; import { ordersModule } from './orders/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. */ -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]; diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index b6758d6..01db998 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -53,6 +53,9 @@ async function loadInvoice(id: string) { return { v, items }; } const notify = (event: string, extra: Record, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId }); +const mailTo = (to: string, template: string, vars: Record, 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 = { 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]); 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 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); // 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. @@ -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 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 }; }); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts new file mode 100644 index 0000000..e1f43e0 --- /dev/null +++ b/apps/api/src/modules/mail/index.ts @@ -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 = { + 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 })); + }); + }, +}; diff --git a/apps/api/src/modules/resources/index.ts b/apps/api/src/modules/resources/index.ts index 465b759..893eb3a 100644 --- a/apps/api/src/modules/resources/index.ts +++ b/apps/api/src/modules/resources/index.ts @@ -10,6 +10,7 @@ import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk'; import { one, query, run } from '../../core/db.js'; import { audit } from '../../core/audit.js'; import { enqueue } from '../../core/jobs.js'; +import { sendTemplateMail } from '../../core/mail.js'; import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js'; import { can, canInOrg } from '../../core/policy.js'; @@ -179,6 +180,21 @@ export const resourcesModule: KcModule = { reply.header('cache-control', 'no-store'); 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 ---------- const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const; diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index a1c052e..efa10d8 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -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, 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, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId }); +const mailTo = (to: string, template: string, vars: Record, 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. */ async function loadTicket(id: string, forCustomer: boolean) { @@ -66,7 +67,10 @@ export const ticketsModule: KcModule = { 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 } }); - 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 }; }); @@ -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 } }); 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 }; }); diff --git a/apps/web/src/app/(app)/einstellungen/email/page.tsx b/apps/web/src/app/(app)/einstellungen/email/page.tsx new file mode 100644 index 0000000..b44c518 --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/email/page.tsx @@ -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 = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' }; +const STATUS: Record = { + 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 {m.label}; }; + +/** 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) { + 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 (
+

SMTP-Verbindung

+ {!s.configured && Noch nicht eingerichtet. Solange Host und Absenderadresse fehlen, werden E-Mails nicht versendet, aber im Protokoll unten als „nicht eingerichtet“ vermerkt.} + {msg && {msg.t}} +
+
+ + + + + + + +
+ {w &&
} +
+ {w &&
+ setTestTo(e.target.value)} style={{ maxWidth: 260 }} /> + +
} +

Zuletzt geändert: {fmt(s.updatedAt)}

+
); +} + +/** 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) { + 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 (
+
+

{t.name}

{t.trigger}

+
{!t.enabled && Deaktiviert}
+
+ {open &&
+ {msg && {msg.t}} +

Verfügbare Daten für diese Vorlage: {t.variables.map((v) => {`{{${v}}}`})}

+
+ +