Feature: Rechnungsversand per E-Mail (eigener SMTP-Client, kein PHPMailer)

This commit is contained in:
fds2610-bot 2026-04-28 14:22:38 +02:00
parent e0bab2e49d
commit 4485acbe1f
10 changed files with 515 additions and 10 deletions

View file

@ -0,0 +1,8 @@
ALTER TABLE company_settings
ADD COLUMN IF NOT EXISTS mail_smtp_host VARCHAR(255) NOT NULL DEFAULT '' AFTER nextcloud_done,
ADD COLUMN IF NOT EXISTS mail_smtp_port SMALLINT UNSIGNED NOT NULL DEFAULT 587 AFTER mail_smtp_host,
ADD COLUMN IF NOT EXISTS mail_smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'tls' AFTER mail_smtp_port,
ADD COLUMN IF NOT EXISTS mail_smtp_user VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_encryption,
ADD COLUMN IF NOT EXISTS mail_smtp_pass VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_user,
ADD COLUMN IF NOT EXISTS mail_from_name VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_pass,
ADD COLUMN IF NOT EXISTS mail_from_email VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_from_name;

View file

@ -103,6 +103,7 @@ $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->canc
$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p)); $router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p));
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
$router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p));
// ── Anlagevermögen ─────────────────────────────────────────── // ── Anlagevermögen ───────────────────────────────────────────
$router->get('/assets', fn($p) => (new AssetController())->index($p)); $router->get('/assets', fn($p) => (new AssetController())->index($p));
@ -130,6 +131,7 @@ $router->get('/tax/euer/{year}/konto/{konto_id}', fn($p) => (new EuerController(
// ── Einstellungen ──────────────────────────────────────────── // ── Einstellungen ────────────────────────────────────────────
$router->get('/settings', fn($p) => (new SettingsController())->show($p)); $router->get('/settings', fn($p) => (new SettingsController())->show($p));
$router->post('/settings', fn($p) => (new SettingsController())->update($p)); $router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));

View file

@ -10,6 +10,7 @@ use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking; use Steuerkram\Models\Booking;
use Steuerkram\Models\Account; use Steuerkram\Models\Account;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\Services\PdfInvoiceService; use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\View; use Steuerkram\View;
@ -197,6 +198,50 @@ class InvoiceController
} }
} }
public function send(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] === 'Entwurf') {
View::redirect("/invoices/{$id}", 'Entwürfe können nicht gesendet werden.', 'error');
return;
}
// Empfänger aus POST (bis zu 3), Fallback: Kunden-E-Mail
$recipients = array_values(array_filter(array_map(
'trim',
array_merge(
[$_POST['email_to'] ?? $invoice['email'] ?? ''],
[$_POST['email_cc1'] ?? ''],
[$_POST['email_cc2'] ?? ''],
)
)));
if (empty($recipients)) {
View::redirect("/invoices/{$id}", 'Keine Empfänger-E-Mail-Adresse angegeben.', 'error');
return;
}
try {
$lines = Invoice::lines($id);
$settings = Setting::get();
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients);
if ($invoice['status'] === 'Finalisiert') {
\Steuerkram\Database::execute(
"UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?",
[$id]
);
}
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
} catch (\RuntimeException $e) {
View::redirect("/invoices/{$id}", 'Fehler beim Senden: ' . $e->getMessage(), 'error');
}
}
public function pdf(array $params): void public function pdf(array $params): void
{ {
$invoice = Invoice::find((int)$params['id']); $invoice = Invoice::find((int)$params['id']);

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers; namespace Steuerkram\Controllers;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\View; use Steuerkram\View;
class SettingsController class SettingsController
@ -89,6 +90,25 @@ class SettingsController
View::redirect('/settings', 'Logo hochgeladen.'); View::redirect('/settings', 'Logo hochgeladen.');
} }
public function mailTest(array $params): void
{
header('Content-Type: application/json');
$settings = Setting::get();
$to = $settings['email'] ?? '';
if (empty($to)) {
echo json_encode(['error' => 'Keine Firmen-E-Mail hinterlegt (Einstellungen → Firmendaten).']);
return;
}
try {
(new EmailService())->sendTestMail($settings, $to);
echo json_encode(['ok' => true]);
} catch (\RuntimeException $e) {
echo json_encode(['error' => $e->getMessage()]);
}
}
public function deleteLogo(array $params): void public function deleteLogo(array $params): void
{ {
Setting::deleteLogo(); Setting::deleteLogo();

View file

@ -36,6 +36,9 @@ class Setting
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
nextcloud_folder=?, nextcloud_done=?, nextcloud_folder=?, nextcloud_done=?,
mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?,
mail_smtp_user=?, mail_smtp_pass=?,
mail_from_name=?, mail_from_email=?,
logo_max_width=?, logo_max_height=?, logo_max_width=?, logo_max_height=?,
logo_position=?, logo_margin_side=?, logo_margin_top=? logo_position=?, logo_margin_side=?, logo_margin_top=?
WHERE id=1', WHERE id=1',
@ -68,6 +71,13 @@ class Setting
$data['nextcloud_pass'] ?: null, $data['nextcloud_pass'] ?: null,
$data['nextcloud_folder'] ?: '/Belege/Import', $data['nextcloud_folder'] ?: '/Belege/Import',
$data['nextcloud_done'] ?: null, $data['nextcloud_done'] ?: null,
$data['mail_smtp_host'] ?: '',
(int)($data['mail_smtp_port'] ?? 587),
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
$data['mail_smtp_user'] ?: '',
$data['mail_smtp_pass'] ?: '',
$data['mail_from_name'] ?: '',
$data['mail_from_email'] ?: '',
max(50, (int)($data['logo_max_width'] ?? 600)), max(50, (int)($data['logo_max_width'] ?? 600)),
max(20, (int)($data['logo_max_height'] ?? 100)), max(20, (int)($data['logo_max_height'] ?? 100)),
$logoPosition, $logoPosition,

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
class EmailService
{
/**
* Sendet eine Rechnung als PDF-Anhang an alle Empfänger.
*
* @param string[] $recipients E-Mail-Adressen (1–3)
* @throws \RuntimeException
*/
public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients): void
{
$mailer = $this->buildMailer($settings);
$pdfService = new PdfInvoiceService();
$pdfContent = $pdfService->renderToString($invoice, $lines, $settings);
$nr = $invoice['rechnungs_nr'] ?? 'Entwurf';
$firma = $settings['firma'] ?? '';
$subject = 'Rechnung ' . $nr;
$filename = 'Rechnung-' . $nr . '.pdf';
$body = "Guten Tag,\n\n"
. "anbei erhalten Sie die Rechnung {$nr} von {$firma}.\n\n"
. "Bei Fragen stehen wir Ihnen gerne zur Verfügung.\n\n"
. "Mit freundlichen Grüßen\n"
. $firma;
$mailer->send($recipients, $subject, $body, $pdfContent, $filename);
}
/**
* Sendet eine Test-Mail an die angegebene Adresse.
*
* @throws \RuntimeException
*/
public function sendTestMail(array $settings, string $to): void
{
$mailer = $this->buildMailer($settings);
$mailer->send(
[$to],
'Steuerkram – SMTP-Test',
"Die SMTP-Konfiguration funktioniert korrekt.\n\nDiese Mail wurde von Steuerkram gesendet."
);
}
private function buildMailer(array $settings): SmtpMailer
{
$host = $settings['mail_smtp_host'] ?? '';
if (empty($host)) {
throw new \RuntimeException('SMTP-Host ist nicht konfiguriert.');
}
return new SmtpMailer(
host: $host,
port: (int)($settings['mail_smtp_port'] ?? 587),
encryption: $settings['mail_smtp_encryption'] ?? 'tls',
user: $settings['mail_smtp_user'] ?? '',
pass: $settings['mail_smtp_pass'] ?? '',
fromEmail: $settings['mail_from_email'] ?: ($settings['email'] ?? ''),
fromName: $settings['mail_from_name'] ?: ($settings['firma'] ?? ''),
);
}
}

View file

@ -8,7 +8,24 @@ use Mpdf\Mpdf;
class PdfInvoiceService class PdfInvoiceService
{ {
public function renderToString(array $invoice, array $lines, array $settings): string
{
return $this->generate($invoice, $lines, $settings);
}
public function render(array $invoice, array $lines, array $settings): void public function render(array $invoice, array $lines, array $settings): void
{
$pdfContent = $this->generate($invoice, $lines, $settings);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdfContent));
echo $pdfContent;
exit;
}
private function generate(array $invoice, array $lines, array $settings): string
{ {
$html = $this->buildHtml($invoice, $lines, $settings); $html = $this->buildHtml($invoice, $lines, $settings);
@ -40,18 +57,11 @@ class PdfInvoiceService
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS); $mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY); $mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$pdfContent = $mpdf->Output('', 'S'); $pdfContent = $mpdf->Output('', 'S');
// ZUGFeRD EN16931-XML einbetten (E-Rechnung) // ZUGFeRD EN16931-XML einbetten (E-Rechnung)
$zugferd = new ZugferdService(); $zugferd = new ZugferdService();
$pdfContent = $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings); return $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdfContent));
echo $pdfContent;
exit;
} }
private function buildHtml(array $inv, array $lines, array $settings): string private function buildHtml(array $inv, array $lines, array $settings): string

237
src/Services/SmtpMailer.php Normal file
View file

@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Schlanker SMTP-Client ohne externe Abhängigkeiten.
* Unterstützt STARTTLS (port 587), SSL/TLS (port 465) und unverschlüsselt.
* AUTH LOGIN und AUTH PLAIN. Bis zu n Empfänger. Einen Anhang (binary string).
*/
class SmtpMailer
{
/** @var resource */
private $sock;
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly string $encryption, // 'tls' | 'ssl' | 'none'
private readonly string $user,
private readonly string $pass,
private readonly string $fromEmail,
private readonly string $fromName,
) {}
/**
* @param string[] $to Liste von Empfänger-Adressen (max. 3)
* @param string $subject
* @param string $body Plain-Text-Body
* @param string|null $attachContent Binärer Inhalt des Anhangs
* @param string|null $attachName Dateiname des Anhangs
* @throws \RuntimeException
*/
public function send(
array $to,
string $subject,
string $body,
?string $attachContent = null,
?string $attachName = null,
): void {
$this->connect();
$this->authenticate();
$this->sendMessage($to, $subject, $body, $attachContent, $attachName);
$this->cmd('QUIT');
fclose($this->sock);
}
// ── Verbindung ───────────────────────────────────────────────
private function connect(): void
{
$address = $this->encryption === 'ssl'
? 'ssl://' . $this->host
: 'tcp://' . $this->host;
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
],
]);
$sock = stream_socket_client(
$address . ':' . $this->port,
$errno, $errstr, 15,
STREAM_CLIENT_CONNECT,
$ctx
);
if (!$sock) {
throw new \RuntimeException("SMTP-Verbindung fehlgeschlagen: {$errstr} ({$errno})");
}
$this->sock = $sock;
stream_set_timeout($this->sock, 15);
$this->expect(220);
$this->ehlo();
if ($this->encryption === 'tls') {
$this->cmd('STARTTLS');
$this->expect(220);
if (!stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('STARTTLS-Aushandlung fehlgeschlagen.');
}
$this->ehlo();
}
}
private function ehlo(): void
{
$this->cmd('EHLO ' . (gethostname() ?: 'localhost'));
$this->expectMulti(250);
}
// ── Authentifizierung ────────────────────────────────────────
private function authenticate(): void
{
$this->cmd('AUTH LOGIN');
$this->expect(334);
$this->cmd(base64_encode($this->user));
$this->expect(334);
$this->cmd(base64_encode($this->pass));
$this->expect(235);
}
// ── Nachricht ────────────────────────────────────────────────
private function sendMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): void {
$this->cmd('MAIL FROM:<' . $this->fromEmail . '>');
$this->expect(250);
foreach ($to as $addr) {
$this->cmd('RCPT TO:<' . $addr . '>');
$this->expect(250);
}
$this->cmd('DATA');
$this->expect(354);
$raw = $this->buildMessage($to, $subject, $body, $attachContent, $attachName);
// Dot-Stuffing: Zeilen, die mit '.' beginnen, bekommen einen zweiten Punkt
$raw = preg_replace('/^\.$/m', '..', $raw);
fwrite($this->sock, $raw . "\r\n.\r\n");
$this->expect(250);
}
private function buildMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): string {
$date = date('r');
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>';
$toHdr = implode(', ', $to);
$subjHdr = $this->encodeHeader($subject);
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
if ($attachContent === null) {
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
]);
}
$boundary = '----=_Part_' . bin2hex(random_bytes(8));
$b64 = chunk_split(base64_encode($attachContent), 76, "\r\n");
$safeName = $this->encodeHeader($attachName ?? 'Anhang.pdf');
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="' . $boundary . '"',
'',
'--' . $boundary,
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
'',
'--' . $boundary,
'Content-Type: application/pdf; name="' . $safeName . '"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="' . $safeName . '"',
'',
$b64,
'--' . $boundary . '--',
]);
}
// ── Hilfsmethoden ────────────────────────────────────────────
private function cmd(string $line): void
{
fwrite($this->sock, $line . "\r\n");
}
private function expect(int $code): string
{
$response = fgets($this->sock, 512);
if ($response === false) {
throw new \RuntimeException('SMTP: Keine Antwort vom Server.');
}
if ((int)$response !== $code) {
throw new \RuntimeException('SMTP: Unerwartete Antwort: ' . trim($response));
}
return $response;
}
/** Liest mehrzeilige SMTP-Antworten (z.B. nach EHLO) */
private function expectMulti(int $code): void
{
do {
$line = fgets($this->sock, 512);
if ($line === false) throw new \RuntimeException('SMTP: Verbindung unterbrochen.');
if ((int)$line !== $code) throw new \RuntimeException('SMTP: ' . trim($line));
} while ($line[3] === '-');
}
private function encodeHeader(string $value): string
{
if (mb_detect_encoding($value, 'ASCII', true)) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
private function quotedPrintable(string $text): string
{
return quoted_printable_encode($text);
}
}

View file

@ -31,7 +31,11 @@ $badgeClass = match($invoice['status']) {
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if ($invoice['status'] !== 'Entwurf'): ?> <?php if ($invoice['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a> <a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-secondary">PDF herunterladen</a>
<?php endif; ?>
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert'])): ?>
<button type="button" class="btn btn-primary"
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
<?php endif; ?> <?php endif; ?>
<?php if ($invoice['status'] === 'Gesendet'): ?> <?php if ($invoice['status'] === 'Gesendet'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a> <a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
@ -311,3 +315,31 @@ $badgeClass = match($invoice['status']) {
} }
}()); }());
</script> </script>
<!-- E-Mail-Senden-Modal -->
<div id="send-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:420px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<h3 style="margin:0 0 1rem">Rechnung per E-Mail senden</h3>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/send">
<div class="form-group">
<label>Empfänger *</label>
<input type="email" name="email_to" required
value="<?= $h($invoice['email'] ?? '') ?>"
placeholder="empfaenger@example.com">
</div>
<div class="form-group">
<label>CC 1 <span style="font-weight:normal;color:#888">(optional)</span></label>
<input type="email" name="email_cc1" placeholder="cc1@example.com">
</div>
<div class="form-group">
<label>CC 2 <span style="font-weight:normal;color:#888">(optional)</span></label>
<input type="email" name="email_cc2" placeholder="cc2@example.com">
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('send-modal').style.display='none'">Abbrechen</button>
<button type="submit" class="btn btn-primary">Senden</button>
</div>
</form>
</div>
</div>

View file

@ -256,11 +256,84 @@
</div> </div>
</div> </div>
<!-- E-Mail-Versand -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">E-Mail-Versand (SMTP)</div></div>
<div class="card-body">
<div class="form-group">
<label>SMTP-Host</label>
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>" placeholder="smtp.example.com">
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Port</label>
<input type="number" name="mail_smtp_port" value="<?= $h($settings['mail_smtp_port'] ?? 587) ?>" min="1" max="65535">
</div>
<div class="form-group">
<label>Verschlüsselung</label>
<select name="mail_smtp_encryption">
<option value="tls" <?= ($settings['mail_smtp_encryption'] ?? 'tls') === 'tls' ? 'selected' : '' ?>>STARTTLS (Port 587)</option>
<option value="ssl" <?= ($settings['mail_smtp_encryption'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (Port 465)</option>
<option value="none" <?= ($settings['mail_smtp_encryption'] ?? '') === 'none' ? 'selected' : '' ?>>Keine</option>
</select>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>SMTP-Benutzername</label>
<input type="text" name="mail_smtp_user" value="<?= $h($settings['mail_smtp_user'] ?? '') ?>" autocomplete="off">
</div>
<div class="form-group">
<label>SMTP-Passwort</label>
<input type="password" name="mail_smtp_pass" value="<?= $h($settings['mail_smtp_pass'] ?? '') ?>" autocomplete="new-password">
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Absendername</label>
<input type="text" name="mail_from_name" value="<?= $h($settings['mail_from_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>Absender-E-Mail</label>
<input type="email" name="mail_from_email" value="<?= $h($settings['mail_from_email'] ?? '') ?>">
</div>
</div>
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
<span id="mail-test-result" style="margin-left:.75rem;font-size:.8rem"></span>
</div>
</div>
</div> </div>
</div> </div>
</form> </form>
<script>
document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this;
var result = document.getElementById('mail-test-result');
btn.disabled = true;
result.textContent = 'Sende…';
result.style.color = '';
fetch('/settings/mail-test', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
result.textContent = '✓ Test-Mail erfolgreich gesendet.';
result.style.color = 'green';
} else {
result.textContent = '✗ ' + (data.error || 'Unbekannter Fehler');
result.style.color = 'red';
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
</script>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
<div></div> <div></div>
<div> <div>