From cb9deabe91d2ebe24cbd217fe6d86f2bb69c9b39 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sun, 7 Dec 2025 01:40:21 +0100 Subject: [PATCH] mail send --- public/assets/js/ui-editor.js | 56 ++++++++++++++-- public/assets/js/ui-list.js | 20 +++++- public/index.php | 22 +++++++ src/ApiKernel.php | 117 ++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 7 deletions(-) diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js index 269da7e..93c6f77 100644 --- a/public/assets/js/ui-editor.js +++ b/public/assets/js/ui-editor.js @@ -18,6 +18,7 @@ export function initEditor() {   const sendForm     = document.getElementById('sendTestForm');   const sendTo       = document.getElementById('send_to');   const sendSubject  = document.getElementById('send_subject'); +  const sendInfo     = document.getElementById('send_template_info');   const btnCancelSend= document.getElementById('btn-cancel-send');   const btnSendNow   = document.getElementById('btn-send-now');   const prevFrame    = document.getElementById('previewFrame'); @@ -36,6 +37,27 @@ export function initEditor() {     return (b?.dataset?.tab) || (current?.resource) || 'templates';   } +  function setSendContext(id, name = '') { +    if (sendDlg) { +      if (id) { +        sendDlg.dataset.templateId = String(id); +      } else { +        delete sendDlg.dataset.templateId; +      } +      if (name) sendDlg.dataset.templateName = name; +    } +    if (sendInfo) { +      if (!id) { +        sendInfo.textContent = 'Kein Template ausgewählt.'; +        sendInfo.classList.add('text-rose-600'); +      } else { +        const label = name ? `${name} – Template #${id}` : `Template #${id}`; +        sendInfo.textContent = label; +        sendInfo.classList.remove('text-rose-600'); +      } +    } +  } +   function writeHtmlToFrame(html) {     iframe.srcdoc = `       @@ -246,6 +268,8 @@ export function initEditor() {     // globaler Kontext     window.__currentItemId    = current.id;     window.__currentEditorCtx = { id: current.id, mode: current.resource }; +    setSendContext(current.id, current.name); +    if (btnTest) btnTest.classList.toggle('hidden', current.resource !== 'templates');     // Neuen Token erzeugen & alten Listener entfernen     reqToken++; @@ -388,24 +412,32 @@ export function initEditor() {     prevDlg?.showModal?.();   }    -  async function openSend() { -    sendSubject.value = 'Testversand'; -    sendTo.value = ''; +  async function openSend(ctx = null) { +    const ctxId = ctx?.id ? Number(ctx.id) : (window.__currentItemId || current?.id || 0); +    if (!ctxId) { +      err('Kein Template geladen'); +      return; +    } +    window.__currentItemId = ctxId; +    const ctxName = ctx?.name ?? sendDlg?.dataset?.templateName ?? current?.name ?? ''; +    setSendContext(ctxId, ctxName); +    if (sendSubject) sendSubject.value = ctx?.subject || 'Testversand'; +    if (sendTo) sendTo.value = ctx?.to || '';     sendDlg?.showModal?.();   }   function closeSend(){ sendDlg?.close?.(); }   async function doSend(ev){     ev?.preventDefault?.(); -    const to = sendTo.value.trim(); -    if(!to){ toast("Bitte Empfänger angeben", false); return; } +    const to = (sendTo?.value || '').trim(); +    if(!to){ err('Bitte Empfänger angeben'); return; }     const win = iframe?.contentWindow;     const ctx = (win && win.__currentEditorCtx) || {};     const id  = (window.__currentItemId || ctx?.id || 0);     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 r = await apiAction('templates.test_send', { method:'POST', data:{ template_id: id, to, subject: (sendSubject?.value || 'Testversand') } });     if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }   }   function closePreview(){ prevDlg?.close?.(); } @@ -440,6 +472,18 @@ export function initEditor() {   btnCancelSend&& (btnCancelSend.onclick= closeSend);   sendForm     && (sendForm.onsubmit    = doSend); +  window.AdminTestSend = window.AdminTestSend || {}; +  window.AdminTestSend.open = (opts = {}) => { +    const targetId = Number(opts.id || window.__currentItemId || 0); +    if (!targetId) { +      err('Testversand: Keine ID vorhanden'); +      return; +    } +    window.__currentItemId = targetId; +    setSendContext(targetId, opts.name || ''); +    openSend({ id: targetId, name: opts.name || '' }); +  }; +   // Public API   window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview }; } diff --git a/public/assets/js/ui-list.js b/public/assets/js/ui-list.js index ecdeb18..03e0401 100644 --- a/public/assets/js/ui-list.js +++ b/public/assets/js/ui-list.js @@ -97,6 +97,9 @@ export async function loadList(resource){ const editBtn = (resource==='snippets') ? `` : ''; + const testBtn = resource==='templates' + ? `` : ''; + const prevBtn = ``; const delBtn = ``; @@ -104,7 +107,7 @@ export async function loadList(resource){
${name || '(ohne Name)'}
#${item.id}
${parentBadge(resource,item)}
-
${[openBtn, editBtn, prevBtn, delBtn].filter(Boolean).join('')}
+
${[openBtn, editBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')}
`; }).join(''); @@ -149,6 +152,21 @@ export async function loadList(resource){ prevDlg.showModal(); })); + // test send (templates only) + list.querySelectorAll('[data-test]').forEach(b=>b.addEventListener('click', ()=>{ + const id = Number(b.dataset.test || '0'); + const nm = b.dataset.name || ''; + if (!id) { + toast('Testversand: Ungültige ID', false); + return; + } + if (window.AdminTestSend && typeof window.AdminTestSend.open === 'function') { + window.AdminTestSend.open({ id, name: nm }); + } else { + toast('Testversand ist aktuell nicht verfügbar.', false); + } + })); + // delete const delDlg=document.getElementById('deleteDialog'), delText=document.getElementById('deleteText'), diff --git a/public/index.php b/public/index.php index df8f1a3..7b7a8b9 100644 --- a/public/index.php +++ b/public/index.php @@ -91,6 +91,8 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
E-Mail Editor + +
@@ -109,6 +111,26 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); + + +
+

Testversand

+

Kein Template ausgewählt.

+ + +
+ + +
+
+
+
diff --git a/src/ApiKernel.php b/src/ApiKernel.php index 5437641..fc1ff29 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -1,6 +1,8 @@ respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]); } + /** + * Sendet einen Testversand für Templates. + */ + private function handleTemplateTestSend(): void + { + $auth = $this->requireAuth(); + $templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0); + if ($templateId <= 0) { + $this->fail('template_id required', null, 422); + } + + $recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], '')); + if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + $this->fail('Valid recipient required', null, 422); + } + + $subject = trim((string)$this->val($this->in, ['subject'], 'Testversand')); + if ($subject === '') { + $subject = 'Testversand'; + } + + $t = $this->tableMap['templates']; + [$idCol, $allCols] = $this->resolveIdCol('templates'); + [$tw, $tp] = $this->tenantWhere($auth); + + $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':id', $templateId); + foreach ($tp as $k => $v) $stmt->bindValue($k, $v); + $stmt->execute(); + $row = $stmt->fetch(); + if (!$row) { + $this->fail('Template not found', ['id' => $templateId], 404); + } + + $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); + $html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : ''; + if ($html === '' && !empty($row['json_content'])) { + $html = '

(Dieses Template enthält noch keine HTML-Inhalte.)

'; + } + $html = $this->prepareEmailHtml($html); + + if (!$this->dispatchTestMail($recipient, $subject, $html)) { + $this->fail('Send failed', null, 500); + } + + $this->respond([ + 'ok' => true, + 'template_id' => $templateId, + 'to' => $recipient, + 'subject' => $subject, + ]); + } + + private function prepareEmailHtml(string $html): string + { + $html = trim($html); + if ($html === '') { + return '

(Kein Inhalt vorhanden)

'; + } + + if (!class_exists(CssToInlineStyles::class)) { + return $html; + } + + try { + $inliner = new CssToInlineStyles(); + return $inliner->convert($html); + } catch (Throwable $e) { + return $html; + } + } + + private function dispatchTestMail(string $to, string $subject, string $html): bool + { + if (!function_exists('mail')) { + return false; + } + + $smtp = $this->conf['smtp'] ?? []; + $fromEmail = $smtp['from_email'] ?? 'no-reply@example.com'; + $fromName = $smtp['from_name'] ?? 'EmailTemplate'; + $headers = [ + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=UTF-8', + 'From: ' . $this->formatEmailAddress($fromEmail, $fromName), + ]; + + $encodedSubject = function_exists('mb_encode_mimeheader') + ? mb_encode_mimeheader($subject, 'UTF-8') + : $subject; + return @mail($to, $encodedSubject, $html, implode("\r\n", $headers)); + } + + private function formatEmailAddress(string $email, string $name): string + { + $email = trim($email); + if ($email === '') { + return 'no-reply@example.com'; + } + + $name = trim($name); + if ($name === '') { + return $email; + } + + $encoded = function_exists('mb_encode_mimeheader') + ? mb_encode_mimeheader($name, 'UTF-8') + : $name; + return sprintf('%s <%s>', $encoded, $email); + } + // ================================================================= // 💡 Öffentliche run()-Methode (KORRIGIERT) // ================================================================= @@ -649,6 +763,9 @@ class ApiKernel $this->authService->logout(); $this->respond(['ok' => true]); break; + case 'templates.test_send': + $this->handleTemplateTestSend(); + break; /* ---------- CRUD HANDLER ---------- */ default: