+
diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js
index 93c6f77..854d3a6 100644
--- a/public/assets/js/ui-editor.js
+++ b/public/assets/js/ui-editor.js
@@ -21,12 +21,16 @@ export function initEditor() {
const sendInfo = document.getElementById('send_template_info');
const btnCancelSend= document.getElementById('btn-cancel-send');
const btnSendNow = document.getElementById('btn-send-now');
+ const sendSender = document.getElementById('send_sender');
+ const sendSenderHint = document.getElementById('send_sender_hint');
const prevFrame = document.getElementById('previewFrame');
const btnPrevClose = document.getElementById('btn-close-preview');
let current = null; // { resource, id, name }
let bridgeListener = null;
let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events
+ let senderOptions = [];
+ let senderLoadPromise = null;
const ok = (m) => toast(m, true);
const err = (m) => toast(m, false);
@@ -228,6 +232,43 @@ export function initEditor() {
return (rows || []).map(r => ({ id: r.id, name: r.name, html: r.content || r.html || '' }));
}
+ async function loadSenderOptions(force = false) {
+ if (!sendSender) return;
+ if (senderLoadPromise && !force) return senderLoadPromise;
+ senderLoadPromise = apiAction('account.senders.list', { method: 'GET' })
+ .then(res => {
+ senderOptions = res?.items || [];
+ renderSenderOptions();
+ })
+ .catch(() => {
+ senderOptions = [];
+ renderSenderOptions();
+ })
+ .finally(() => {
+ senderLoadPromise = null;
+ });
+ return senderLoadPromise;
+ }
+
+ function renderSenderOptions() {
+ if (!sendSender) return;
+ const previous = sendSender.value;
+ let html = '
';
+ senderOptions.forEach(opt => {
+ const label = opt.label || opt.from_name || opt.from_email;
+ html += `
`;
+ });
+ sendSender.innerHTML = html;
+ if (previous && senderOptions.some(opt => String(opt.id) === previous)) {
+ sendSender.value = previous;
+ } else {
+ sendSender.value = '';
+ }
+ if (sendSenderHint) {
+ sendSenderHint.classList.toggle('hidden', senderOptions.length > 0);
+ }
+ }
+
// ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token, hasJson, json }) {
if (token !== reqToken) return; // veraltete Anfrage ignorieren
@@ -423,9 +464,13 @@ export function initEditor() {
setSendContext(ctxId, ctxName);
if (sendSubject) sendSubject.value = ctx?.subject || 'Testversand';
if (sendTo) sendTo.value = ctx?.to || '';
+ await loadSenderOptions(true);
sendDlg?.showModal?.();
}
- function closeSend(){ sendDlg?.close?.(); }
+ function closeSend(){
+ sendDlg?.close?.();
+ if (sendSender) sendSender.value = '';
+ }
async function doSend(ev){
ev?.preventDefault?.();
@@ -437,7 +482,15 @@ export function initEditor() {
if(!id){ toast("Kein Template geladen", false); return; }
// Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
// keine Live-Daten erwartet. Es geht um template_id.
- const r = await apiAction('templates.test_send', { method:'POST', data:{ template_id: id, to, subject: (sendSubject?.value || 'Testversand') } });
+ const payload = {
+ template_id: id,
+ to,
+ subject: (sendSubject?.value || 'Testversand'),
+ };
+ if (sendSender && sendSender.value) {
+ payload.sender_id = Number(sendSender.value);
+ }
+ const r = await apiAction('templates.test_send', { method:'POST', data: payload });
if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
}
function closePreview(){ prevDlg?.close?.(); }
@@ -488,6 +541,15 @@ export function initEditor() {
window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
}
+function escapeHtml(str) {
+ return String(str || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
// Default-Export + globaler Fallback
export default initEditor;
window.initEditor = initEditor;
diff --git a/public/assets/js/ui-user.js b/public/assets/js/ui-user.js
index 4dac3ab..ad4c6a1 100644
--- a/public/assets/js/ui-user.js
+++ b/public/assets/js/ui-user.js
@@ -5,6 +5,9 @@ const state = {
rotate: { bridge: false, sender: false, external: false },
users: [],
userMap: new Map(),
+ senders: [],
+ senderMap: new Map(),
+ bridgeTables: [],
currentTab: 'profile',
loading: false,
};
@@ -15,6 +18,10 @@ let passwordForm;
let settingsForm;
let teamTable;
let userForm;
+let senderTable;
+let senderForm;
+let bridgePreview;
+let validateBridgeBtn;
export function initUserPanel() {
avatarBtn = document.getElementById('btn-user');
@@ -27,15 +34,25 @@ export function initAccountPage() {
settingsForm = document.getElementById('settingsForm');
teamTable = document.getElementById('teamTable');
userForm = document.getElementById('userForm');
+ senderTable = document.getElementById('senderTable');
+ senderForm = document.getElementById('senderForm');
+ bridgePreview = document.getElementById('bridgeTablesPreview');
+ validateBridgeBtn = document.getElementById('btn-validate-bridge');
document.getElementById('btn-user-add')?.addEventListener('click', () => openUserForm());
document.getElementById('userFormCancel')?.addEventListener('click', () => closeUserForm());
userForm?.addEventListener('submit', submitUserForm);
+ document.getElementById('btn-sender-add')?.addEventListener('click', () => openSenderForm());
+ document.getElementById('senderFormCancel')?.addEventListener('click', () => closeSenderForm());
+ senderForm?.addEventListener('submit', submitSenderForm);
+
profileForm?.addEventListener('submit', submitProfileForm);
passwordForm?.addEventListener('submit', submitPasswordForm);
settingsForm?.addEventListener('submit', submitSettingsForm);
teamTable?.addEventListener('click', handleTeamTableClick);
+ senderTable?.addEventListener('click', handleSenderTableClick);
+ validateBridgeBtn?.addEventListener('click', validateBridgeSettings);
document.querySelectorAll('[data-user-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.getAttribute('data-user-tab')));
@@ -60,12 +77,18 @@ export function initAccountPage() {
switchTab(state.currentTab);
loadAccountData();
+ updateRoleVisibility();
}
function isOwner() {
return (window.__currentUser?.role || '').toLowerCase() === 'owner';
}
+function isAdmin() {
+ const role = (window.__currentUser?.role || '').toLowerCase();
+ return role === 'owner' || role === 'admin';
+}
+
function updateAvatar() {
const target = document.getElementById('userAvatar');
if (!target) return;
@@ -73,7 +96,19 @@ function updateAvatar() {
target.textContent = name ? name.trim().charAt(0).toUpperCase() : 'U';
}
-function updateOwnerVisibility() {
+function updateRoleVisibility() {
+ const role = (window.__currentUser?.role || '').toLowerCase();
+ document.querySelectorAll('[data-role]').forEach(el => {
+ const allowed = (el.getAttribute('data-role') || '').split(/[\s,]+/).filter(Boolean).map(r => r.toLowerCase());
+ if (!allowed.length) return;
+ const visible = allowed.some(targetRole => {
+ if (targetRole === 'owner') return role === 'owner';
+ if (targetRole === 'admin') return role === 'owner' || role === 'admin';
+ if (targetRole === 'editor') return role === 'owner' || role === 'admin' || role === 'editor';
+ return true;
+ });
+ el.classList.toggle('hidden', !visible);
+ });
document.querySelectorAll('.owner-only').forEach(el => {
el.classList.toggle('hidden', !isOwner());
});
@@ -100,13 +135,20 @@ async function loadAccountData() {
if (res.user) {
window.__currentUser = res.user;
updateAvatar();
- updateOwnerVisibility();
+ updateRoleVisibility();
}
fillProfileForm(res.user);
fillSettingsForm(res.settings || {});
if (isOwner()) {
await loadUsers();
}
+ if (isAdmin()) {
+ await loadSenders();
+ } else {
+ state.senders = [];
+ state.senderMap = new Map();
+ renderSenderList();
+ }
} catch (err) {
console.error(err);
toast(err.message || 'Fehler beim Laden', false);
@@ -128,6 +170,9 @@ function fillSettingsForm(settings) {
settingsForm.bridge_token.value = settings.bridge_token || '';
settingsForm.sender_token.value = settings.sender_token || '';
settingsForm.external_api_token.value = settings.external_api_token || '';
+ const tables = Array.isArray(settings.bridge_tables) ? settings.bridge_tables : [];
+ settingsForm.bridge_tables ? settingsForm.bridge_tables.value = tables.join(', ') : null;
+ applyBridgePreview(tables);
state.rotate = { bridge: false, sender: false, external: false };
}
@@ -174,6 +219,7 @@ async function submitSettingsForm(ev) {
rotate_bridge_token: state.rotate.bridge ? 1 : 0,
rotate_sender_token: state.rotate.sender ? 1 : 0,
rotate_external_token: state.rotate.external ? 1 : 0,
+ bridge_tables: parseBridgeTablesInput(),
};
try {
const res = await apiAction('account.settings.update', { method: 'POST', data });
@@ -207,6 +253,50 @@ async function downloadFile(type) {
}
}
+function parseBridgeTablesInput() {
+ if (!settingsForm) return [];
+ const raw = settingsForm.bridge_tables?.value || '';
+ return raw
+ .split(/[\s,]+/)
+ .map(part => part.trim())
+ .filter(Boolean);
+}
+
+function applyBridgePreview(tables) {
+ state.bridgeTables = Array.isArray(tables) ? tables : [];
+ if (!bridgePreview) return;
+ if (!state.bridgeTables.length) {
+ bridgePreview.innerHTML = '
Keine Einschränkung – alle Tabellen erlaubt.';
+ return;
+ }
+ bridgePreview.innerHTML = state.bridgeTables.map(name => `
${escapeHtml(name)}`).join('');
+}
+
+async function validateBridgeSettings(ev) {
+ ev?.preventDefault();
+ if (!settingsForm) return;
+ const data = {
+ bridge_url: settingsForm.bridge_url.value.trim(),
+ bridge_token: settingsForm.bridge_token.value.trim(),
+ };
+ if (!data.bridge_url || !data.bridge_token) {
+ toast('Bitte Bridge-URL und Token angeben', false);
+ return;
+ }
+ try {
+ const res = await apiAction('account.bridge.test', { method: 'POST', data });
+ if (!res?.ok) throw new Error(res?.error || 'Prüfung fehlgeschlagen');
+ const tables = Array.isArray(res.tables) ? res.tables : [];
+ applyBridgePreview(tables);
+ if (settingsForm.bridge_tables) {
+ settingsForm.bridge_tables.value = tables.join(', ');
+ }
+ toast('Bridge erfolgreich geprüft', true);
+ } catch (err) {
+ toast(err.message || 'Prüfung fehlgeschlagen', false);
+ }
+}
+
async function loadUsers() {
try {
const res = await apiAction('account.users.list', { method: 'GET' });
@@ -329,6 +419,110 @@ function copyToClipboard(value) {
}
}
+async function loadSenders() {
+ if (!senderTable) return;
+ try {
+ const res = await apiAction('account.senders.list', { method: 'GET' });
+ if (!res?.ok) throw new Error(res?.error || 'Absender konnten nicht geladen werden');
+ state.senders = res.items || [];
+ state.senderMap = new Map(state.senders.map(item => [item.id, item]));
+ renderSenderList();
+ } catch (err) {
+ toast(err.message || 'Fehler beim Laden der Absender', false);
+ }
+}
+
+function renderSenderList() {
+ if (!senderTable) return;
+ const tbody = senderTable.querySelector('tbody');
+ if (!tbody) return;
+ if (!state.senders.length) {
+ tbody.innerHTML = '
| Keine Absender vorhanden. |
';
+ return;
+ }
+ tbody.innerHTML = state.senders.map(sender => `
+
+ | ${escapeHtml(sender.label || sender.from_name || sender.from_email)} |
+ ${escapeHtml(sender.from_name || '—')} |
+ ${escapeHtml(sender.from_email)} |
+ ${escapeHtml(sender.reply_to || '')} |
+
+
+
+ |
+
+ `).join('');
+}
+
+function handleSenderTableClick(ev) {
+ const btn = ev.target.closest('button[data-sender-action]');
+ if (!btn) return;
+ const id = Number(btn.getAttribute('data-sender-id'));
+ const action = btn.getAttribute('data-sender-action');
+ const sender = state.senderMap.get(id);
+ if (!sender) return;
+ if (action === 'edit') {
+ openSenderForm(sender);
+ } else if (action === 'delete') {
+ if (confirm(`Absender "${sender.label || sender.from_email}" wirklich löschen?`)) {
+ deleteSender(id);
+ }
+ }
+}
+
+function openSenderForm(sender = null) {
+ if (!senderForm) return;
+ senderForm.classList.remove('hidden');
+ senderForm.sender_id.value = sender?.id || '';
+ senderForm.label.value = sender?.label || '';
+ senderForm.from_name.value = sender?.from_name || '';
+ senderForm.from_email.value = sender?.from_email || '';
+ senderForm.reply_to.value = sender?.reply_to || '';
+}
+
+function closeSenderForm() {
+ if (!senderForm) return;
+ senderForm.classList.add('hidden');
+ senderForm.reset();
+ senderForm.sender_id.value = '';
+}
+
+async function submitSenderForm(ev) {
+ ev.preventDefault();
+ if (!senderForm) return;
+ const payload = {
+ sender_id: senderForm.sender_id.value ? Number(senderForm.sender_id.value) : undefined,
+ label: senderForm.label.value.trim(),
+ from_name: senderForm.from_name.value.trim(),
+ from_email: senderForm.from_email.value.trim(),
+ reply_to: senderForm.reply_to.value.trim(),
+ };
+ if (!payload.from_email) {
+ toast('Bitte eine Absenderadresse angeben', false);
+ return;
+ }
+ try {
+ const res = await apiAction('account.senders.save', { method: 'POST', data: payload });
+ if (!res?.ok) throw new Error(res?.error || 'Speichern fehlgeschlagen');
+ closeSenderForm();
+ await loadSenders();
+ toast('Absender gespeichert', true);
+ } catch (err) {
+ toast(err.message || 'Fehler beim Speichern', false);
+ }
+}
+
+async function deleteSender(senderId) {
+ try {
+ const res = await apiAction('account.senders.delete', { method: 'POST', data: { sender_id: senderId } });
+ if (!res?.ok) throw new Error(res?.error || 'Löschen fehlgeschlagen');
+ await loadSenders();
+ toast('Absender gelöscht', true);
+ } catch (err) {
+ toast(err.message || 'Fehler beim Löschen', false);
+ }
+}
+
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&')
diff --git a/public/index.php b/public/index.php
index 0f36723..e5a88bc 100644
--- a/public/index.php
+++ b/public/index.php
@@ -132,6 +132,13 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
Betreff
+
diff --git a/schema.sql b/schema.sql
index 8e30495..109daeb 100644
--- a/schema.sql
+++ b/schema.sql
@@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_customer_settings` (
`bridge_token` varchar(255) DEFAULT NULL,
`sender_token` varchar(255) DEFAULT NULL,
`external_api_token` varchar(255) DEFAULT NULL,
+ `bridge_tables` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`customer_id`)
@@ -122,6 +123,22 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_customer_tokens` (
CONSTRAINT `emailtemplate_customer_tokens_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+-- Tabelle: emailtemplate_sender_identities
+CREATE TABLE IF NOT EXISTS `emailtemplate_sender_identities` (
+ `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `customer_id` int(10) unsigned NOT NULL,
+ `label` varchar(255) NOT NULL,
+ `from_name` varchar(255) DEFAULT NULL,
+ `from_email` varchar(255) NOT NULL,
+ `reply_to` varchar(255) DEFAULT NULL,
+ `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
+ `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (`id`),
+ KEY `idx_sender_customer` (`customer_id`),
+ KEY `idx_sender_email` (`from_email`),
+ CONSTRAINT `fk_sender_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+
-- Tabelle: emailtemplate_customer_users
CREATE TABLE IF NOT EXISTS `emailtemplate_customer_users` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -228,4 +245,4 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_template_items` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;
--- Ende des Schema-Dumps
\ No newline at end of file
+-- Ende des Schema-Dumps
diff --git a/src/ApiKernel.php b/src/ApiKernel.php
index abc65e5..a544e8b 100644
--- a/src/ApiKernel.php
+++ b/src/ApiKernel.php
@@ -642,6 +642,7 @@ class ApiKernel
if ($subject === '') {
$subject = 'Testversand';
}
+ $senderId = (int)$this->val($this->in, ['sender_id'], 0);
$t = $this->tableMap['templates'];
[$idCol, $allCols] = $this->resolveIdCol('templates');
@@ -668,7 +669,15 @@ class ApiKernel
$html = $this->renderHtmlWithReferences($html, $auth, $renderCache, $renderStack);
$html = $this->prepareEmailHtml($html);
- if (!$this->dispatchTestMail($recipient, $subject, $html)) {
+ $sender = null;
+ if ($senderId > 0) {
+ $customerId = (int)($auth['customer_id'] ?? 0);
+ if ($customerId > 0) {
+ $sender = $this->fetchSenderRow($customerId, $senderId);
+ }
+ }
+
+ if (!$this->dispatchTestMail($recipient, $subject, $html, $sender)) {
$this->fail('Send failed', null, 500);
}
@@ -677,6 +686,7 @@ class ApiKernel
'template_id' => $templateId,
'to' => $recipient,
'subject' => $subject,
+ 'sender_id' => $senderId > 0 ? $senderId : null,
]);
}
@@ -699,20 +709,24 @@ class ApiKernel
}
}
- private function dispatchTestMail(string $to, string $subject, string $html): bool
+ private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null): bool
{
if (!function_exists('mail')) {
return false;
}
$smtp = $this->conf['smtp'] ?? [];
- $fromEmail = $smtp['from_email'] ?? 'no-reply@example.com';
- $fromName = $smtp['from_name'] ?? 'EmailTemplate';
+ $fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com');
+ $fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate'));
+ $replyTo = $sender['reply_to'] ?? '';
$headers = [
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
];
+ if ($replyTo !== '') {
+ $headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail);
+ }
$encodedSubject = function_exists('mb_encode_mimeheader')
? mb_encode_mimeheader($subject, 'UTF-8')
@@ -797,12 +811,24 @@ class ApiKernel
case 'account.users.delete':
$this->handleAccountUsersDelete();
break;
+ case 'account.senders.list':
+ $this->handleAccountSendersList();
+ break;
+ case 'account.senders.save':
+ $this->handleAccountSenderSave();
+ break;
+ case 'account.senders.delete':
+ $this->handleAccountSenderDelete();
+ break;
case 'downloads.bridge':
$this->handleDownloadFile('bridge');
break;
case 'downloads.sender':
$this->handleDownloadFile('sender');
break;
+ case 'account.bridge.test':
+ $this->handleAccountBridgeTest();
+ break;
case 'placeholders.schema':
$this->handlePlaceholderSchema();
break;
@@ -1080,9 +1106,15 @@ class ApiKernel
return;
}
+ $settings = $this->getCustomerSettings($customerId);
+ $tables = $schema['tables'] ?? [];
+ if (!empty($settings['bridge_tables'])) {
+ $tables = $this->filterSchemaTables($tables, $settings['bridge_tables']);
+ }
+
$this->respond([
'ok' => true,
- 'tables' => $schema['tables'] ?? [],
+ 'tables' => $tables,
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
]);
}
@@ -1237,7 +1269,7 @@ class ApiKernel
private function handleAccountSettingsGet(): void
{
$user = $this->authService->requireAuth();
- $this->ensureOwner($user);
+ $this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
$this->respond(['ok' => true, 'settings' => $settings]);
@@ -1246,7 +1278,7 @@ class ApiKernel
private function handleAccountSettingsUpdate(): void
{
$user = $this->authService->requireAuth();
- $this->ensureOwner($user);
+ $this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
@@ -1254,6 +1286,8 @@ class ApiKernel
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
+ $bridgeTablesInput = $this->in['bridge_tables'] ?? null;
+ $bridgeTables = $this->normalizeBridgeTables($bridgeTablesInput);
$rotateBridge = !empty($this->in['rotate_bridge_token']);
$rotateSender = !empty($this->in['rotate_sender_token']);
$rotateExternal = !empty($this->in['rotate_external_token']);
@@ -1272,6 +1306,7 @@ class ApiKernel
'bridge_token' => $bridgeToken,
'sender_token' => $senderToken,
'external_api_token' => $externalToken,
+ 'bridge_tables' => $bridgeTables,
]);
$this->respond(['ok' => true, 'settings' => $settings]);
@@ -1473,10 +1508,86 @@ class ApiKernel
$this->respond(['ok' => true, 'deleted' => true]);
}
+ private function handleAccountSendersList(): void
+ {
+ $user = $this->authService->requireAuth();
+ $customerId = (int)($user['customer_id'] ?? 0);
+ if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
+ $table = $this->senderTable();
+ $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC");
+ $stmt->execute([':cid' => $customerId]);
+ $items = [];
+ while ($row = $stmt->fetch()) {
+ $items[] = $this->formatSenderRow($row);
+ }
+ $this->respond(['ok' => true, 'items' => $items]);
+ }
+
+ private function handleAccountSenderSave(): void
+ {
+ $user = $this->authService->requireAuth();
+ $this->ensureRole($user, ['owner', 'admin']);
+ $customerId = (int)($user['customer_id'] ?? 0);
+
+ $senderId = (int)($this->in['sender_id'] ?? 0);
+ $label = trim((string)($this->in['label'] ?? ''));
+ $fromName = trim((string)($this->in['from_name'] ?? ''));
+ $fromEmail = trim((string)($this->in['from_email'] ?? ''));
+ $replyTo = trim((string)($this->in['reply_to'] ?? ''));
+ if ($label === '') $label = $fromName ?: $fromEmail;
+ if ($fromEmail === '' || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
+ $this->fail('Gültige Absender-Adresse erforderlich', null, 422);
+ }
+ if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
+ $this->fail('Ungültige Reply-To-Adresse', null, 422);
+ }
+
+ $table = $this->senderTable();
+ if ($senderId > 0) {
+ $stmt = $this->pdo->prepare("UPDATE `$table` SET `label`=:label,`from_name`=:fname,`from_email`=:fmail,`reply_to`=:reply,`updated_at`=NOW() WHERE `id`=:id AND `customer_id`=:cid LIMIT 1");
+ $stmt->execute([
+ ':label' => $label,
+ ':fname' => $fromName ?: null,
+ ':fmail' => $fromEmail,
+ ':reply' => $replyTo ?: null,
+ ':id' => $senderId,
+ ':cid' => $customerId,
+ ]);
+ if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
+ } else {
+ $stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`from_name`,`from_email`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:fname,:fmail,:reply,NOW(),NOW())");
+ $stmt->execute([
+ ':cid' => $customerId,
+ ':label' => $label,
+ ':fname' => $fromName ?: null,
+ ':fmail' => $fromEmail,
+ ':reply' => $replyTo ?: null,
+ ]);
+ $senderId = (int)$this->pdo->lastInsertId();
+ }
+
+ $sender = $this->fetchSenderRow($customerId, $senderId);
+ $this->respond(['ok' => true, 'sender' => $sender]);
+ }
+
+ private function handleAccountSenderDelete(): void
+ {
+ $user = $this->authService->requireAuth();
+ $this->ensureRole($user, ['owner', 'admin']);
+ $customerId = (int)($user['customer_id'] ?? 0);
+ $senderId = (int)($this->in['sender_id'] ?? 0);
+ if ($senderId <= 0) $this->fail('Ungültige Sender-ID', null, 422);
+ $table = $this->senderTable();
+ $stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
+ $stmt->execute([':id' => $senderId, ':cid' => $customerId]);
+ if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
+ $this->respond(['ok' => true, 'deleted' => true]);
+ }
+
private function handleDownloadFile(string $type): void
{
$user = $this->authService->requireAuth();
- $this->ensureOwner($user);
+ $this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
@@ -1511,6 +1622,34 @@ class ApiKernel
]);
}
+ private function handleAccountBridgeTest(): void
+ {
+ $user = $this->authService->requireAuth();
+ $this->ensureRole($user, ['owner', 'admin']);
+ $customerId = (int)($user['customer_id'] ?? 0);
+ $bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
+ $bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
+ if ($bridgeUrl === '' || $bridgeToken === '') {
+ $settings = $this->getCustomerSettings($customerId);
+ if ($bridgeUrl === '') $bridgeUrl = (string)($settings['bridge_url'] ?? '');
+ if ($bridgeToken === '') $bridgeToken = (string)($settings['bridge_token'] ?? '');
+ }
+ if ($bridgeUrl === '' || $bridgeToken === '') {
+ $this->fail('Bridge nicht konfiguriert', null, 422);
+ }
+ try {
+ $schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0);
+ } catch (Throwable $e) {
+ $this->fail('Bridge request failed', $e->getMessage(), 502);
+ return;
+ }
+ $this->respond([
+ 'ok' => true,
+ 'tables' => $schema['tables'] ?? [],
+ 'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
+ ]);
+ }
+
private function resolveBridgeConfig(?int $customerId): array
{
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
@@ -1528,15 +1667,19 @@ class ApiKernel
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
$stmt->execute([':id' => $customerId]);
$row = $stmt->fetch();
- return $row ?: [];
+ return $row ? $this->formatCustomerSettingsRow($row) : [];
}
private function saveCustomerSettings(int $customerId, array $data): array
{
if ($customerId <= 0) return [];
- $allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token'];
+ $allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'bridge_tables'];
$fields = array_intersect_key($data, array_flip($allowed));
if (!$fields) return $this->getCustomerSettings($customerId);
+ if (array_key_exists('bridge_tables', $fields)) {
+ $normalized = $this->normalizeBridgeTables($fields['bridge_tables']);
+ $fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null;
+ }
$fields['customer_id'] = $customerId;
$columns = array_keys($fields);
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
@@ -1572,6 +1715,76 @@ class ApiKernel
return $settings;
}
+ private function formatCustomerSettingsRow(array $row): array
+ {
+ if (array_key_exists('bridge_tables', $row)) {
+ $row['bridge_tables'] = $this->decodeBridgeTables($row['bridge_tables']);
+ } else {
+ $row['bridge_tables'] = [];
+ }
+ return $row;
+ }
+
+ private function normalizeBridgeTables($input): array
+ {
+ $items = [];
+ if (is_string($input)) {
+ $items = preg_split('/[\s,]+/', $input) ?: [];
+ } elseif (is_array($input)) {
+ $items = $input;
+ } elseif ($input === null) {
+ return [];
+ } else {
+ $items = [$input];
+ }
+ $items = array_map(static function ($value) {
+ return trim((string)$value);
+ }, $items);
+ $items = array_filter($items, fn($value) => $value !== '');
+ return array_values(array_unique($items));
+ }
+
+ private function encodeBridgeTables(?array $tables): ?string
+ {
+ if (empty($tables)) return null;
+ return json_encode(array_values($tables), JSON_UNESCAPED_SLASHES);
+ }
+
+ private function decodeBridgeTables($stored): array
+ {
+ if (is_array($stored)) {
+ return $this->normalizeBridgeTables($stored);
+ }
+ $str = (string)$stored;
+ if ($str === '') return [];
+ $decoded = json_decode($str, true);
+ if (is_array($decoded)) {
+ return $this->normalizeBridgeTables($decoded);
+ }
+ return $this->normalizeBridgeTables($str);
+ }
+
+ private function filterSchemaTables(array $tables, array $allowed): array
+ {
+ if (empty($allowed)) return $tables;
+ $allowedLower = array_map('strtolower', $allowed);
+ $filtered = [];
+ foreach ($tables as $entry) {
+ if (is_array($entry)) {
+ $name = strtolower((string)($entry['name'] ?? $entry['table'] ?? $entry['label'] ?? ''));
+ if ($name !== '' && in_array($name, $allowedLower, true)) {
+ $filtered[] = $entry;
+ }
+ } else {
+ $name = strtolower((string)$entry);
+ if ($name !== '' && in_array($name, $allowedLower, true)) {
+ $filtered[] = $entry;
+ }
+ }
+ }
+ return $filtered;
+ }
+
private function customerSettingsTable(): string
{
return 'emailtemplate_customer_settings';
@@ -1666,6 +1879,37 @@ class ApiKernel
]);
}
+ private function senderTable(): string
+ {
+ return 'emailtemplate_sender_identities';
+ }
+
+ private function fetchSenderRow(int $customerId, int $senderId): array
+ {
+ if ($customerId <= 0 || $senderId <= 0) {
+ $this->fail('Absender nicht gefunden', null, 404);
+ }
+ $table = $this->senderTable();
+ $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
+ $stmt->execute([':id' => $senderId, ':cid' => $customerId]);
+ $row = $stmt->fetch();
+ if (!$row) $this->fail('Absender nicht gefunden', null, 404);
+ return $this->formatSenderRow($row);
+ }
+
+ private function formatSenderRow(array $row): array
+ {
+ return [
+ 'id' => (int)($row['id'] ?? 0),
+ 'label' => $row['label'] ?? '',
+ 'from_name' => $row['from_name'] ?? '',
+ 'from_email' => $row['from_email'] ?? '',
+ 'reply_to' => $row['reply_to'] ?? '',
+ 'created_at' => $row['created_at'] ?? null,
+ 'updated_at' => $row['updated_at'] ?? null,
+ ];
+ }
+
private function formatUserOutput(array $row): array
{
return [
@@ -1720,8 +1964,15 @@ class ApiKernel
private function ensureOwner(array $user): void
{
- if (($user['role'] ?? '') !== 'owner') {
- $this->fail('Nur Owner dürfen diese Aktion ausführen', null, 403);
+ $this->ensureRole($user, ['owner']);
+ }
+
+ private function ensureRole(array $user, array $roles): void
+ {
+ $role = strtolower((string)($user['role'] ?? ''));
+ $allowed = array_values(array_unique(array_map('strtolower', $roles)));
+ if (!in_array($role, $allowed, true)) {
+ $this->fail('Unzureichende Berechtigungen', null, 403);
}
}