Add configurable SMTP email settings and contact-form email notifications

New admin panel "E-Mail" tab (Einstellungen -> E-Mail) lets the shop
owner configure their SMTP server, test the connection with a real
test email, set which address receives new-inquiry notifications, and
edit both the customer confirmation email and the shop notification
email as templates with {{placeholder}} variables.

Previously SMTP config only lived in a non-DB config.php file (with a
blank host, so mail sending was effectively off) and there was no
customer confirmation email at all - only a hardcoded owner
notification. ContactController now sends both emails using the
DB-configured (or config.php-fallback) settings; mail sending stays
best-effort so a contact form submission never fails because of it.

Backend: new Mailer support class (config resolution, PHPMailer setup,
placeholder rendering) and MailSettingsController (show/update/test),
following the existing WebsiteSettings/DatabaseSettings conventions
(password never returned in plaintext, empty password on save keeps
the existing one). New app_settings columns wired into Schema.php so
the "Datenbankstruktur aktualisieren" admin button picks them up on
existing installs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-07 00:54:17 +02:00
parent 967eac0563
commit 0de35ef692
10 changed files with 675 additions and 39 deletions

View file

@ -37,6 +37,7 @@ import AdminUsers from './pages/admin/AdminUsers.jsx';
import PermissionGroups from './pages/admin/PermissionGroups.jsx'; import PermissionGroups from './pages/admin/PermissionGroups.jsx';
import SettingsLayout from './pages/admin/settings/SettingsLayout.jsx'; import SettingsLayout from './pages/admin/settings/SettingsLayout.jsx';
import WebsiteSettings from './pages/admin/settings/WebsiteSettings.jsx'; import WebsiteSettings from './pages/admin/settings/WebsiteSettings.jsx';
import EmailSettings from './pages/admin/settings/EmailSettings.jsx';
import DatabaseSettings from './pages/admin/settings/DatabaseSettings.jsx'; import DatabaseSettings from './pages/admin/settings/DatabaseSettings.jsx';
import ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx'; import ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx';
import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx'; import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx';
@ -89,6 +90,7 @@ export default function App() {
> >
<Route index element={<Navigate to="website" replace />} /> <Route index element={<Navigate to="website" replace />} />
<Route path="website" element={<WebsiteSettings />} /> <Route path="website" element={<WebsiteSettings />} />
<Route path="email" element={<EmailSettings />} />
<Route path="database" element={<DatabaseSettings />} /> <Route path="database" element={<DatabaseSettings />} />
<Route path="export-import" element={<ExportImportSettings />} /> <Route path="export-import" element={<ExportImportSettings />} />
<Route path="maintenance" element={<MaintenanceSettings />} /> <Route path="maintenance" element={<MaintenanceSettings />} />

View file

@ -0,0 +1,297 @@
import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js';
const emptyForm = {
mail_host: '',
mail_port: 587,
mail_username: '',
mail_password: '',
mail_encryption: 'tls',
mail_from_email: '',
mail_from_name: '',
mail_notify_email: '',
mail_customer_subject: '',
mail_customer_body: '',
mail_owner_subject: '',
mail_owner_body: '',
};
const PLACEHOLDERS = [
['{{name}}', 'Name des Kunden'],
['{{email}}', 'E-Mail des Kunden'],
['{{phone}}', 'Telefonnummer'],
['{{vin}}', 'Fahrgestellnummer (FIN)'],
['{{message}}', 'Nachricht des Kunden'],
['{{brand}}', 'Marke'],
['{{model}}', 'Modell'],
['{{package}}', 'Paket'],
['{{product}}', 'Produkt'],
['{{upgrades}}', 'Gewählte Upgrades'],
];
function PlaceholderLegend() {
return (
<div className="mt-3 flex flex-wrap gap-1.5">
{PLACEHOLDERS.map(([token, label]) => (
<span
key={token}
title={label}
className="rounded-md bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300"
>
{token}
</span>
))}
</div>
);
}
export default function EmailSettings() {
const [form, setForm] = useState(emptyForm);
const [hasPassword, setHasPassword] = useState(false);
const [loading, setLoading] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [saveBusy, setSaveBusy] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [testBusy, setTestBusy] = useState(false);
const [testError, setTestError] = useState('');
const [testSuccess, setTestSuccess] = useState('');
useEffect(() => {
api
.get('/settings/mail')
.then((res) => {
setForm({ ...res, mail_password: '' });
setHasPassword(res.has_password);
})
.finally(() => setLoading(false));
}, []);
const update = (field, value) => {
setForm((f) => ({ ...f, [field]: value }));
setSaveSuccess(false);
};
const handleSave = async (e) => {
e.preventDefault();
setSaveBusy(true);
setSaveError('');
setSaveSuccess(false);
try {
const res = await api.post('/settings/mail', form);
setForm({ ...res, mail_password: '' });
setHasPassword(res.has_password);
setSaveSuccess(true);
} catch (err) {
setSaveError(err.message);
} finally {
setSaveBusy(false);
}
};
const handleTest = async () => {
setTestBusy(true);
setTestError('');
setTestSuccess('');
try {
const res = await api.post('/settings/mail/test', form);
setTestSuccess(`Testmail gesendet an ${res.sent_to}.`);
} catch (err) {
setTestError(err.message);
} finally {
setTestBusy(false);
}
};
if (loading) {
return <p className="text-sm text-neutral-400">Lädt</p>;
}
return (
<form onSubmit={handleSave} className="max-w-2xl space-y-6">
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">SMTP-Server</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Zugangsdaten deines Mail-Postfachs oder Transactional-Mail-Anbieters. Solange kein Server eingetragen ist,
werden keine E-Mails verschickt (Kontaktanfragen werden trotzdem in der Datenbank gespeichert).
</p>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_120px]">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Server (Host)</label>
<input
value={form.mail_host}
onChange={(e) => update('mail_host', e.target.value)}
placeholder="smtp.beispiel.de"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Port</label>
<input
type="number"
value={form.mail_port}
onChange={(e) => update('mail_port', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Verschlüsselung</label>
<select
value={form.mail_encryption}
onChange={(e) => update('mail_encryption', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="tls">STARTTLS</option>
<option value="ssl">SSL</option>
<option value="none">Keine</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Benutzername</label>
<input
value={form.mail_username}
onChange={(e) => update('mail_username', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Passwort {hasPassword ? '(leer lassen = unverändert)' : ''}
</label>
<div className="flex gap-2">
<input
type={showPassword ? 'text' : 'password'}
value={form.mail_password}
onChange={(e) => update('mail_password', e.target.value)}
placeholder={hasPassword ? '••••••••' : ''}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="shrink-0 rounded-md border border-neutral-300 px-3 text-sm dark:border-neutral-700"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Absender-E-Mail</label>
<input
type="email"
value={form.mail_from_email}
onChange={(e) => update('mail_from_email', e.target.value)}
placeholder="info@hifi-planet-amorbach.de"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Absender-Name</label>
<input
value={form.mail_from_name}
onChange={(e) => update('mail_from_name', e.target.value)}
placeholder="HifiPlanet"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</div>
<div className="mt-4 border-t border-neutral-200 pt-4 dark:border-neutral-800">
{testError && <p className="mb-2 text-sm text-red-600">{testError}</p>}
{testSuccess && <p className="mb-2 text-sm text-green-600 dark:text-green-400">{testSuccess}</p>}
<button
type="button"
onClick={handleTest}
disabled={testBusy}
className="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
{testBusy ? 'Sende Testmail…' : 'Verbindung testen'}
</button>
</div>
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Empfänger</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
An diese Adresse geht die Benachrichtigung bei jeder neuen Kontaktanfrage.
</p>
<input
type="email"
value={form.mail_notify_email}
onChange={(e) => update('mail_notify_email', e.target.value)}
placeholder="info@hifi-planet-amorbach.de"
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">E-Mail an den Kunden (Bestätigung)</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Geht automatisch an den Kunden, sobald er eine Kontaktanfrage abschickt.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Betreff</label>
<input
value={form.mail_customer_subject}
onChange={(e) => update('mail_customer_subject', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Text</label>
<textarea
rows={8}
value={form.mail_customer_body}
onChange={(e) => update('mail_customer_body', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</div>
<PlaceholderLegend />
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">E-Mail an den Shop (Benachrichtigung)</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Geht an die oben eingestellte Empfänger-Adresse, sobald eine neue Kontaktanfrage eingeht.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Betreff</label>
<input
value={form.mail_owner_subject}
onChange={(e) => update('mail_owner_subject', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Text</label>
<textarea
rows={8}
value={form.mail_owner_body}
onChange={(e) => update('mail_owner_body', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
</div>
<PlaceholderLegend />
</section>
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
{saveSuccess && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
<button
type="submit"
disabled={saveBusy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{saveBusy ? 'Speichere…' : 'Speichern'}
</button>
</form>
);
}

View file

@ -2,6 +2,7 @@ import { NavLink, Outlet } from 'react-router-dom';
const tabs = [ const tabs = [
{ to: '/admin/settings/website', label: 'Website' }, { to: '/admin/settings/website', label: 'Website' },
{ to: '/admin/settings/email', label: 'E-Mail' },
{ to: '/admin/settings/database', label: 'Datenbank' }, { to: '/admin/settings/database', label: 'Datenbank' },
{ to: '/admin/settings/export-import', label: 'Export & Import' }, { to: '/admin/settings/export-import', label: 'Export & Import' },
{ to: '/admin/settings/maintenance', label: 'Wartungsmodus' }, { to: '/admin/settings/maintenance', label: 'Wartungsmodus' },

View file

@ -0,0 +1,18 @@
-- SMTP-Einstellungen und E-Mail-Vorlagen (Kundenbestaetigung + Shop-Benachrichtigung)
-- admin-konfigurierbar machen. Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS mail_host VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS mail_port INT NULL,
ADD COLUMN IF NOT EXISTS mail_username VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS mail_password VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS mail_encryption VARCHAR(10) NOT NULL DEFAULT 'tls',
ADD COLUMN IF NOT EXISTS mail_from_email VARCHAR(150) NULL,
ADD COLUMN IF NOT EXISTS mail_from_name VARCHAR(150) NULL,
ADD COLUMN IF NOT EXISTS mail_notify_email VARCHAR(150) NULL,
ADD COLUMN IF NOT EXISTS mail_customer_subject VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS mail_customer_body TEXT NULL,
ADD COLUMN IF NOT EXISTS mail_owner_subject VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS mail_owner_body TEXT NULL;

View file

@ -24,6 +24,18 @@ CREATE TABLE app_settings (
whatsapp VARCHAR(50) NULL, whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL, contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,
mail_password VARCHAR(255) NULL,
mail_encryption VARCHAR(10) NOT NULL DEFAULT 'tls',
mail_from_email VARCHAR(150) NULL,
mail_from_name VARCHAR(150) NULL,
mail_notify_email VARCHAR(150) NULL,
mail_customer_subject VARCHAR(255) NULL,
mail_customer_body TEXT NULL,
mail_owner_subject VARCHAR(255) NULL,
mail_owner_body TEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB; ) ENGINE=InnoDB;

View file

@ -16,6 +16,7 @@ use App\Controllers\DatabaseConfigController;
use App\Controllers\GalleryBrandController; use App\Controllers\GalleryBrandController;
use App\Controllers\GalleryPhotoController; use App\Controllers\GalleryPhotoController;
use App\Controllers\GalleryProjectController; use App\Controllers\GalleryProjectController;
use App\Controllers\MailSettingsController;
use App\Controllers\MaintenanceController; use App\Controllers\MaintenanceController;
use App\Controllers\ModelController; use App\Controllers\ModelController;
use App\Controllers\PackageController; use App\Controllers\PackageController;
@ -174,6 +175,10 @@ $router->post('/settings/schema-migrate', $perm('settings.manage', fn($p) => Sch
$router->get('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::show())); $router->get('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::show()));
$router->post('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::update())); $router->post('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::update()));
$router->get('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsController::show()));
$router->post('/settings/mail', $perm('settings.manage', fn($p) => MailSettingsController::update()));
$router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSettingsController::test()));
$router->get('/maintenance', fn($p) => MaintenanceController::status()); $router->get('/maintenance', fn($p) => MaintenanceController::status());
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update())); $router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));

View file

@ -4,7 +4,7 @@ namespace App\Controllers;
use App\Config\Database; use App\Config\Database;
use App\Support\Http; use App\Support\Http;
use PHPMailer\PHPMailer\PHPMailer; use App\Support\Mailer;
class ContactController class ContactController
{ {
@ -43,7 +43,9 @@ class ContactController
]); ]);
$id = (int) $db->lastInsertId(); $id = (int) $db->lastInsertId();
self::sendNotificationMail($body, $name, $email, $selectedUpgrades); $vars = self::templateVars($body, $name, $email, $selectedUpgrades);
self::sendOwnerNotification($vars);
self::sendCustomerConfirmation($vars, $email);
Http::send(['ok' => true, 'id' => $id], 201); Http::send(['ok' => true, 'id' => $id], 201);
} }
@ -105,51 +107,65 @@ class ContactController
Http::send(['ok' => true]); Http::send(['ok' => true]);
} }
private static function sendNotificationMail(array $body, string $name, string $email, array $selectedUpgrades = []): void /**
* Baut die Platzhalter-Werte fuer beide Vorlagen (Kundenbestaetigung + Shop-Benachrichtigung)
* einmal zentral, statt sie in jeder Mail-Methode einzeln zusammenzusetzen.
*/
private static function templateVars(array $body, string $name, string $email, array $selectedUpgrades): array
{ {
$config = require __DIR__ . '/../../config/config.php'; $upgradesText = '-';
$mailConfig = $config['mail']; if ($selectedUpgrades) {
$upgradesText = implode("\n", array_map(
fn($u) => sprintf('- %s (%.2f €)', $u['name'], $u['price']),
$selectedUpgrades
));
}
if (empty($mailConfig['host'])) { return [
'name' => $name,
'email' => $email,
'phone' => $body['phone'] ?? null,
'vin' => $body['vin'] ?? null,
'message' => $body['message'] ?? null,
'brand' => $body['brand_name'] ?? null,
'model' => $body['model_name'] ?? null,
'package' => $body['package_name'] ?? null,
'product' => $body['product_name'] ?? null,
'upgrades' => $upgradesText,
];
}
private static function sendOwnerNotification(array $vars): void
{
$config = Mailer::resolveConfig();
if (empty($config['host']) || empty($config['notify_email'])) {
return; return;
} }
try { try {
$mail = new PHPMailer(true); $mail = Mailer::build($config);
$mail->isSMTP(); $mail->addAddress($config['notify_email']);
$mail->Host = $mailConfig['host']; $mail->addReplyTo($vars['email'], $vars['name']);
$mail->Port = $mailConfig['port']; $mail->Subject = Mailer::render($config['owner_subject'], $vars);
$mail->SMTPAuth = true; $mail->Body = Mailer::render($config['owner_body'], $vars);
$mail->Username = $mailConfig['username']; $mail->send();
$mail->Password = $mailConfig['password']; } catch (\Throwable $e) {
$mail->SMTPSecure = $mailConfig['encryption']; // Mailversand ist best effort die Anfrage ist bereits in der DB gespeichert.
$mail->CharSet = 'UTF-8'; }
}
$mail->setFrom($mailConfig['from_email'], $mailConfig['from_name']); private static function sendCustomerConfirmation(array $vars, string $email): void
$mail->addAddress($mailConfig['to_email']); {
$mail->addReplyTo($email, $name); $config = Mailer::resolveConfig();
if (empty($config['host'])) {
$context = trim(sprintf( return;
"Marke: %s\nModell: %s\nPaket: %s\nProdukt: %s", }
$body['brand_name'] ?? '-',
$body['model_name'] ?? '-',
$body['package_name'] ?? '-',
$body['product_name'] ?? '-'
));
$upgradesText = '-';
if ($selectedUpgrades) {
$upgradesText = implode("\n", array_map(
fn($u) => sprintf('- %s (%.2f €)', $u['name'], $u['price']),
$selectedUpgrades
));
}
$mail->Subject = 'Neue Kontaktanfrage von ' . $name;
$mail->Body = "Name: {$name}\nE-Mail: {$email}\nTelefon: " . ($body['phone'] ?? '-')
. "\nFahrgestellnummer (FIN): " . ($body['vin'] ?? '-') . "\n\n"
. $context . "\n\nGewünschte Upgrades:\n" . $upgradesText . "\n\nNachricht:\n" . ($body['message'] ?? '-');
try {
$mail = Mailer::build($config);
$mail->addAddress($email, $vars['name']);
$mail->Subject = Mailer::render($config['customer_subject'], $vars);
$mail->Body = Mailer::render($config['customer_body'], $vars);
$mail->send(); $mail->send();
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Mailversand ist best effort die Anfrage ist bereits in der DB gespeichert. // Mailversand ist best effort die Anfrage ist bereits in der DB gespeichert.

View file

@ -0,0 +1,134 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
use App\Support\Mailer;
class MailSettingsController
{
// Greifen, solange in den Einstellungen noch nichts eingetragen wurde. Nur die Vorlagen
// haben sinnvolle Default-Texte - die SMTP-Zugangsdaten starten bewusst leer.
private const DEFAULTS = [
'mail_host' => '',
'mail_port' => 587,
'mail_username' => '',
'mail_encryption' => 'tls',
'mail_from_email' => '',
'mail_from_name' => 'HifiPlanet',
'mail_notify_email' => '',
'mail_customer_subject' => Mailer::DEFAULT_CUSTOMER_SUBJECT,
'mail_customer_body' => Mailer::DEFAULT_CUSTOMER_BODY,
'mail_owner_subject' => Mailer::DEFAULT_OWNER_SUBJECT,
'mail_owner_body' => Mailer::DEFAULT_OWNER_BODY,
];
private static function currentRow(): array
{
try {
$stmt = Database::connection()->query(
'SELECT mail_host, mail_port, mail_username, mail_password, mail_encryption,
mail_from_email, mail_from_name, mail_notify_email,
mail_customer_subject, mail_customer_body, mail_owner_subject, mail_owner_body
FROM app_settings WHERE id = 1'
);
return $stmt->fetch() ?: [];
} catch (\Throwable $e) {
return [];
}
}
public static function show(): void
{
$row = self::currentRow();
$result = [];
foreach (self::DEFAULTS as $key => $default) {
$result[$key] = ($row[$key] ?? null) !== null && $row[$key] !== '' ? $row[$key] : $default;
}
unset($result['mail_password']);
Http::send(array_merge($result, [
'has_password' => !empty($row['mail_password']),
]));
}
public static function update(): void
{
$body = Http::jsonBody();
$db = Database::connection();
$current = self::currentRow();
// Leeres Passwort im Formular = aktuelles Passwort beibehalten (wird beim Laden nie
// im Klartext an den Browser geschickt, siehe show()).
$password = ($body['mail_password'] ?? '') !== '' ? $body['mail_password'] : ($current['mail_password'] ?? null);
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare(
'UPDATE app_settings SET
mail_host = ?, mail_port = ?, mail_username = ?, mail_password = ?, mail_encryption = ?,
mail_from_email = ?, mail_from_name = ?, mail_notify_email = ?,
mail_customer_subject = ?, mail_customer_body = ?, mail_owner_subject = ?, mail_owner_body = ?
WHERE id = 1'
);
$stmt->execute([
trim($body['mail_host'] ?? '') ?: null,
!empty($body['mail_port']) ? (int) $body['mail_port'] : null,
trim($body['mail_username'] ?? '') ?: null,
$password ?: null,
trim($body['mail_encryption'] ?? '') ?: 'tls',
trim($body['mail_from_email'] ?? '') ?: null,
trim($body['mail_from_name'] ?? '') ?: null,
trim($body['mail_notify_email'] ?? '') ?: null,
trim($body['mail_customer_subject'] ?? '') ?: null,
$body['mail_customer_body'] ?? null,
trim($body['mail_owner_subject'] ?? '') ?: null,
$body['mail_owner_body'] ?? null,
]);
self::show();
}
/**
* Schickt eine echte Testmail mit den (moeglicherweise noch ungespeicherten) Formularwerten,
* damit der Admin sich nicht erst durchklicken muss, um zu sehen, ob die SMTP-Daten stimmen.
*/
public static function test(): void
{
$body = Http::jsonBody();
$current = self::currentRow();
$host = trim($body['mail_host'] ?? '');
$fromEmail = trim($body['mail_from_email'] ?? '');
$target = trim($body['mail_notify_email'] ?? '') ?: $fromEmail;
$password = ($body['mail_password'] ?? '') !== '' ? $body['mail_password'] : ($current['mail_password'] ?? '');
if ($host === '' || $fromEmail === '') {
Http::error('Server und Absender-E-Mail werden fuer den Test benoetigt', 422);
}
if (!filter_var($target, FILTER_VALIDATE_EMAIL)) {
Http::error('Empfaenger-E-Mail (Benachrichtigungs- oder Absenderadresse) ist ungueltig', 422);
}
try {
$mail = Mailer::build([
'host' => $host,
'port' => $body['mail_port'] ?? 587,
'username' => trim($body['mail_username'] ?? ''),
'password' => $password,
'encryption' => trim($body['mail_encryption'] ?? '') ?: 'tls',
'from_email' => $fromEmail,
'from_name' => trim($body['mail_from_name'] ?? '') ?: 'HifiPlanet',
]);
$mail->addAddress($target);
$mail->Subject = 'Testmail von HifiPlanet';
$mail->Body = "Diese Testmail bestaetigt, dass deine SMTP-Einstellungen funktionieren.";
$mail->send();
} catch (\Throwable $e) {
Http::error('Test fehlgeschlagen: ' . $e->getMessage(), 422);
}
Http::send(['ok' => true, 'sent_to' => $target]);
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Support;
use App\Config\Database;
use PHPMailer\PHPMailer\PHPMailer;
/**
* Buendelt alles rund um SMTP-Konfiguration und E-Mail-Vorlagen an einer Stelle,
* damit ContactController (echter Versand) und MailSettingsController (Verbindungstest)
* dieselbe PHPMailer-Aufbau- und Platzhalter-Logik nutzen statt sie zu duplizieren.
*/
class Mailer
{
public const DEFAULT_CUSTOMER_SUBJECT = 'Deine Anfrage bei HifiPlanet ist eingegangen';
public const DEFAULT_CUSTOMER_BODY = <<<'TXT'
Hallo {{name}},
vielen Dank fuer deine Anfrage bei HifiPlanet! Wir haben sie erhalten und melden uns so schnell wie moeglich bei dir.
Deine Angaben:
Marke: {{brand}}
Modell: {{model}}
Paket: {{package}}
Produkt: {{product}}
Nachricht:
{{message}}
Viele Gruesse
Dein HifiPlanet-Team
TXT;
public const DEFAULT_OWNER_SUBJECT = 'Neue Kontaktanfrage von {{name}}';
public const DEFAULT_OWNER_BODY = <<<'TXT'
Name: {{name}}
E-Mail: {{email}}
Telefon: {{phone}}
Fahrgestellnummer (FIN): {{vin}}
Marke: {{brand}}
Modell: {{model}}
Paket: {{package}}
Produkt: {{product}}
Gewuenschte Upgrades:
{{upgrades}}
Nachricht:
{{message}}
TXT;
/**
* Liest die SMTP-/Vorlagen-Einstellungen aus app_settings. Leere Felder fallen auf
* die statische config.php zurueck (Zweck: Bestandsinstallationen, bei denen host/from_email
* dort schon manuell eingetragen wurden, funktionieren weiter, bis im Admin-Panel gespeichert wird).
*/
public static function resolveConfig(): array
{
$fallback = (require __DIR__ . '/../../config/config.php')['mail'];
try {
$stmt = Database::connection()->query(
'SELECT mail_host, mail_port, mail_username, mail_password, mail_encryption,
mail_from_email, mail_from_name, mail_notify_email,
mail_customer_subject, mail_customer_body, mail_owner_subject, mail_owner_body
FROM app_settings WHERE id = 1'
);
$row = $stmt->fetch() ?: [];
} catch (\Throwable $e) {
$row = [];
}
$str = fn($value) => $value !== null && $value !== '' ? $value : null;
return [
'host' => $str($row['mail_host'] ?? null) ?? $fallback['host'],
'port' => $str($row['mail_port'] ?? null) ?? $fallback['port'],
'username' => $str($row['mail_username'] ?? null) ?? $fallback['username'],
'password' => $str($row['mail_password'] ?? null) ?? $fallback['password'],
'encryption' => $str($row['mail_encryption'] ?? null) ?? $fallback['encryption'],
'from_email' => $str($row['mail_from_email'] ?? null) ?? $fallback['from_email'],
'from_name' => $str($row['mail_from_name'] ?? null) ?? $fallback['from_name'],
'notify_email' => $str($row['mail_notify_email'] ?? null) ?? $fallback['to_email'],
'customer_subject' => $str($row['mail_customer_subject'] ?? null) ?? self::DEFAULT_CUSTOMER_SUBJECT,
'customer_body' => $str($row['mail_customer_body'] ?? null) ?? self::DEFAULT_CUSTOMER_BODY,
'owner_subject' => $str($row['mail_owner_subject'] ?? null) ?? self::DEFAULT_OWNER_SUBJECT,
'owner_body' => $str($row['mail_owner_body'] ?? null) ?? self::DEFAULT_OWNER_BODY,
];
}
/**
* Baut einen fertig konfigurierten, aber noch nicht abgeschickten PHPMailer aus den
* uebergebenen SMTP-Feldern (host/port/username/password/encryption/from_email/from_name).
*/
public static function build(array $config): PHPMailer
{
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $config['host'];
$mail->Port = (int) $config['port'];
$mail->SMTPAuth = true;
$mail->Username = $config['username'];
$mail->Password = $config['password'];
$mail->SMTPSecure = ($config['encryption'] ?? '') !== 'none' ? $config['encryption'] : false;
$mail->CharSet = 'UTF-8';
$mail->setFrom($config['from_email'], $config['from_name'] ?: $config['from_email']);
return $mail;
}
/**
* Ersetzt {{platzhalter}} in einer Vorlage. Unbekannte Platzhalter bleiben unveraendert
* stehen (auffaelliger als sie einfach zu leeren, falls sich im Text ein Tippfehler einschleicht).
*/
public static function render(string $template, array $vars): string
{
$replacements = [];
foreach ($vars as $key => $value) {
$replacements['{{' . $key . '}}'] = $value ?? '-';
}
return strtr($template, $replacements);
}
}

View file

@ -35,6 +35,18 @@ class Schema
whatsapp VARCHAR(50) NULL, whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL, contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,
mail_password VARCHAR(255) NULL,
mail_encryption VARCHAR(10) NOT NULL DEFAULT 'tls',
mail_from_email VARCHAR(150) NULL,
mail_from_name VARCHAR(150) NULL,
mail_notify_email VARCHAR(150) NULL,
mail_customer_subject VARCHAR(255) NULL,
mail_customer_body TEXT NULL,
mail_owner_subject VARCHAR(255) NULL,
mail_owner_body TEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB", ) ENGINE=InnoDB",
@ -230,6 +242,18 @@ class Schema
'whatsapp' => 'VARCHAR(50) NULL', 'whatsapp' => 'VARCHAR(50) NULL',
'contact_email' => 'VARCHAR(150) NULL', 'contact_email' => 'VARCHAR(150) NULL',
'hero_image_path' => 'VARCHAR(255) NULL', 'hero_image_path' => 'VARCHAR(255) NULL',
'mail_host' => 'VARCHAR(255) NULL',
'mail_port' => 'INT NULL',
'mail_username' => 'VARCHAR(255) NULL',
'mail_password' => 'VARCHAR(255) NULL',
'mail_encryption' => "VARCHAR(10) NOT NULL DEFAULT 'tls'",
'mail_from_email' => 'VARCHAR(150) NULL',
'mail_from_name' => 'VARCHAR(150) NULL',
'mail_notify_email' => 'VARCHAR(150) NULL',
'mail_customer_subject' => 'VARCHAR(255) NULL',
'mail_customer_body' => 'TEXT NULL',
'mail_owner_subject' => 'VARCHAR(255) NULL',
'mail_owner_body' => 'TEXT NULL',
'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
], ],
'services' => [ 'services' => [