diff --git a/config/domaindata.php b/config/domaindata.php new file mode 100644 index 0000000..fe8f705 --- /dev/null +++ b/config/domaindata.php @@ -0,0 +1,4 @@ + 0, + 'path' => '/', + 'domain' => APP_COOKIE_DOMAIN ?: '', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); + } +} + + +// ----------------------------------------------------------- +// 2) Persistente Client-ID (für Tracking über Besuche hinweg) +// ----------------------------------------------------------- +if (php_sapi_name() !== 'cli') { + $clientId = $_COOKIE[$clientCookieName] ?? null; + + // Erwartet wird: 64 Hex-Zeichen (32 Bytes) + if ( + !is_string($clientId) || + $clientId === '' || + !preg_match('/^[a-f0-9]{64}$/', $clientId) + ) { + // neue ID erzeugen + try { + $clientId = bin2hex(random_bytes(32)); // 32 bytes → 64 hex + } catch (Throwable $e) { + $clientId = bin2hex(openssl_random_pseudo_bytes(32)); + } + + $cookieOpts = [ + 'expires' => time() + APP_CLIENT_COOKIE_LIFETIME, + 'path' => '/', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => false, // JS darf es lesen, wenn erwünscht + 'samesite' => 'Lax', + ]; + + if (!empty(APP_COOKIE_DOMAIN)) { + $cookieOpts['domain'] = APP_COOKIE_DOMAIN; + } + + setcookie($clientCookieName, $clientId, $cookieOpts); + $_COOKIE[$clientCookieName] = $clientId; + } + + // global verfügbar machen (NEUER NAME!) + $GLOBALS['cookie_client_id'] = $clientId; +} + + +// ----------------------------------------------------------- +// 3) Sprachlogik laden (bleibt sinnvoll zentral) +// ----------------------------------------------------------- +require_once __DIR__ . '/i18n.php'; + + +// ----------------------------------------------------------- +// 4) Rest des Systems laden (DB, Funktionen, Hilfs-Libs) +// ----------------------------------------------------------- +require_once __DIR__ . "/db.php"; +require_once __DIR__ . '/../src/functions.php'; diff --git a/config/i18n.php b/config/i18n.php new file mode 100644 index 0000000..2621bc8 --- /dev/null +++ b/config/i18n.php @@ -0,0 +1,397 @@ + 2) { + $code = substr($code, 0, 2); + } + + if ($code === '') { + return null; + } + + $label = isset($meta['label']) && $meta['label'] !== '' + ? (string)$meta['label'] + : strtoupper($code); + + $flag = isset($meta['flag']) ? (string)$meta['flag'] : ''; + + return [ + 'code' => $code, + 'label' => $label, + 'flag' => $flag, + ]; +} + +/** + * Alle verfügbaren Sprachen aus /public/assets/i18n/*.json ermitteln. + * Verfügbar = JSON mit meta.enabled === true. + * EN wird garantiert hinzugefügt (Fallback), falls nicht gefunden. + */ +function app_i18n_detect_available_languages(): array +{ + $baseDir = realpath(__DIR__ . '/../public/assets/i18n'); + if ($baseDir === false) { + // Wenn gar kein Verzeichnis da ist: minimaler EN-Fallback + return [ + 'en' => [ + 'code' => 'en', + 'label' => 'English', + 'flag' => '', + ], + ]; + } + + $files = glob($baseDir . '/*.json') ?: []; + $langs = []; + + foreach ($files as $file) { + $meta = app_i18n_load_language_meta_from_file($file); + if ($meta === null) { + continue; + } + + $code = $meta['code']; + + // Erste gültige Definition pro Code gewinnt + if (!isset($langs[$code])) { + $langs[$code] = $meta; + } + } + + // EN muss immer vorhanden sein (laut deiner Vorgabe) + if (!isset($langs['en'])) { + // Versuch: gibt es eine en.json, auch wenn enabled=false? + foreach ($files as $file) { + $base = strtolower(basename($file, '.json')); + if ($base === 'en') { + $json = @file_get_contents($file); + $data = json_decode($json, true); + $meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; + + $label = isset($meta['label']) && $meta['label'] !== '' + ? (string)$meta['label'] + : 'English'; + $flag = isset($meta['flag']) ? (string)$meta['flag'] : ''; + + $langs['en'] = [ + 'code' => 'en', + 'label' => $label, + 'flag' => $flag, + ]; + break; + } + } + } + + // Wenn immer noch kein EN → minimaler Stub + if (!isset($langs['en'])) { + $langs['en'] = [ + 'code' => 'en', + 'label' => 'English', + 'flag' => '', + ]; + } + + ksort($langs); + + return $langs; +} + +/** + * Browsersprache aus HTTP_ACCEPT_LANGUAGE extrahieren (2-Buchstaben), + * aber nur, wenn sie in $available existiert. + */ +function app_i18n_detect_browser_lang(array $available): ?string +{ + $header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''; + if ($header === '') { + return null; + } + + $parts = explode(',', $header); + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + $code = strtolower(substr($part, 0, 2)); // "de-DE" → "de" + if (isset($available[$code])) { + return $code; + } + } + + return null; +} + +/** + * Aktuelle Sprache bestimmen: + * 1) ?lang=xx (wenn in $available) + * 2) Browsersprache (wenn in $available) + * 3) Fallback "en" + */ +function app_i18n_resolve_current_lang(array $available): string +{ + // 1) URL-Parameter ?lang=xx + if (!empty($_GET['lang'])) { + $param = strtolower(substr($_GET['lang'], 0, 2)); + if (isset($available[$param])) { + return $param; + } + } + + // 2) Browsersprache + $browser = app_i18n_detect_browser_lang($available); + if ($browser !== null) { + return $browser; + } + + // 3) Standard EN + if (isset($available['en'])) { + return 'en'; + } + + // Sicherheitsfallback: erste verfügbare Sprache + $keys = array_keys($available); + return $keys[0] ?? 'en'; +} + +// ----------------------------------------------------- +// Bootstrap ausführen +// ----------------------------------------------------- + +$availableLangs = app_i18n_detect_available_languages(); +$currentLang = app_i18n_resolve_current_lang($availableLangs); + +// Global bereitstellen +$GLOBALS['availableLangs'] = $availableLangs; +$GLOBALS['lang'] = $currentLang; + +// Optional in Session merken (muss nicht, schadet aber auch nicht) +$_SESSION['lang'] = $currentLang; + +/** + * Frontend-Config für JS (usbConfig.i18n) + * → benutzt direkt die Meta-Daten aus den JSONs (code, label, flag) + */ +function app_i18n_get_frontend_config(): array +{ + return [ + 'current' => $GLOBALS['lang'] ?? 'en', + 'available' => $GLOBALS['availableLangs'] ?? [], + ]; +} + +/** + * Komplette JSON-Struktur für eine Sprache laden. + * Nutzt einfachen Request-Cache, damit pro Sprache nur einmal von Platte gelesen wird. + */ +function app_i18n_load_lang_json(string $lang): array +{ + static $cache = []; + + $lang = strtolower(substr($lang, 0, 5)); + + if (isset($cache[$lang])) { + return $cache[$lang]; + } + + $baseDir = realpath(__DIR__ . '/../public/assets/i18n'); + if ($baseDir === false) { + $cache[$lang] = []; + return $cache[$lang]; + } + + $path = $baseDir . '/' . $lang . '.json'; + if (!is_file($path)) { + // Fallback: en.json, falls vorhanden + $fallback = $baseDir . '/en.json'; + if (is_file($fallback)) { + $json = @file_get_contents($fallback); + $data = json_decode($json, true); + $cache[$lang] = is_array($data) ? $data : []; + return $cache[$lang]; + } + + $cache[$lang] = []; + return $cache[$lang]; + } + + $json = @file_get_contents($path); + $data = json_decode($json, true); + $cache[$lang] = is_array($data) ? $data : []; + + return $cache[$lang]; +} + +/** + * Aus einem Label einen stabilen i18n-Key für Nav-Anker bauen. + * Beispiel: "So funktioniert USBCheck!" -> "nav_so_funktioniert_usbcheck" + */ +function app_i18n_make_anchor_key(string $label): string +{ + // HTML-Entities entfernen (z. B. &) + $decoded = html_entity_decode($label, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Kleinbuchstaben + $decoded = mb_strtolower($decoded, 'UTF-8'); + + // Alles, was kein a-z oder 0-9 ist, durch Unterstrich ersetzen + $key = preg_replace('/[^a-z0-9]+/u', '_', $decoded); + + // Mehrfache Unterstriche trimmen + $key = trim($key, '_'); + + if ($key === '') { + $key = 'item'; + } + + // Prefix, damit klar ist, dass es Navigationskeys sind + return 'nav_' . $key; +} + +/** + * Nav-Anker für eine Seite aus der Sprachdatei holen. + * + * Haupt-Variante im JSON: + * + * "pages": { + * "landing": { + * "anchors": { + * "how": "So funktioniert USBCheck", + * "problem": "Warum gefälschte USB-Sticks gefährlich sind", + * "features": "Funktionen", + * "security": "Sicherheit", + * "faq": "FAQ" + * } + * } + * } + * + * Optional explizit: + * "anchors": { + * "how": { "label": "So funktioniert USBCheck", "i18n": "nav_how" }, + * "faq": { "i18n": "nav_faq" } + * } + * + * Rückgabe-Format: + * [ + * [ 'href' => '#how', 'label' => 'So funktioniert USBCheck', 'i18n' => 'nav_so_funktioniert_usbcheck' ], + * [ 'href' => '#faq', 'label' => '', 'i18n' => 'nav_faq' ], + * ] + */ +function app_get_nav_anchors(string $pageKey): array +{ + $lang = $GLOBALS['lang'] ?? 'en'; + $data = app_i18n_load_lang_json($lang); + + $cfg = $data['pages'][$pageKey]['anchors'] ?? null; + if (!is_array($cfg)) { + return []; + } + + $anchors = []; + + foreach ($cfg as $id => $value) { + $id = trim((string)$id); + if ($id === '') { + continue; + } + + $href = '#' . $id; + $label = ''; + $i18n = ''; + + if (is_string($value)) { + // String IMMER als Label übernehmen + $labelTrim = trim($value); + if ($labelTrim === '') { + continue; + } + + $label = $labelTrim; + // i18n-Key automatisch aus dem Label ableiten + $i18n = app_i18n_make_anchor_key($labelTrim); + + } elseif (is_array($value)) { + // Explizite Variante: + // "how": { "label": "...", "i18n": "nav_how" } + if (!empty($value['label'])) { + $label = trim((string)$value['label']); + } + if (!empty($value['i18n'])) { + $i18n = trim((string)$value['i18n']); + } + + if ($label === '' && $i18n === '') { + continue; + } + + // Wenn Label gesetzt, aber kein i18n: automatisch generieren + if ($label !== '' && $i18n === '') { + $i18n = app_i18n_make_anchor_key($label); + } + } else { + // Weder String noch Array → ignorieren + continue; + } + + $anchors[] = [ + 'href' => $href, + 'label' => $label, + 'i18n' => $i18n, + ]; + } + + return $anchors; +} diff --git a/config/prod/config.php b/config/prod/config.php new file mode 100644 index 0000000..b11bace --- /dev/null +++ b/config/prod/config.php @@ -0,0 +1,34 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO( + "mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4", + $DB_USER, + $DB_PASS, + $options + ); +} catch (PDOException $e) { + // In Produktion Logging, keine Details ausgeben + http_response_code(500); + echo 'Database connection error.'; + exit; +} diff --git a/config/staging/config.php b/config/staging/config.php new file mode 100644 index 0000000..e2ea33f --- /dev/null +++ b/config/staging/config.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO( + "mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4", + $DB_USER, + $DB_PASS, + $options + ); +} catch (PDOException $e) { + // In Produktion Logging, keine Details ausgeben + http_response_code(500); + echo 'Database connection error.'; + exit; +} diff --git a/public/test/emailtemplate_bridge.php b/public/test/emailtemplate_bridge.php new file mode 100644 index 0000000..a9b22bc --- /dev/null +++ b/public/test/emailtemplate_bridge.php @@ -0,0 +1,175 @@ + getenv('EMAILTEMPLATE_BRIDGE_TOKEN') ?: 'kgIqdL9aNWsFWy6mhSRpnuLc1EbZ62sGCcJAwjjlqqznEGE13szhksWUan0cEdjE', + 'db' => [ + 'dsn' => getenv('EMAILTEMPLATE_BRIDGE_DSN') ?: 'mysql:host=127.0.0.1;dbname=d0444c25;charset=utf8mb4', + 'user' => getenv('EMAILTEMPLATE_BRIDGE_DB_USER') ?: 'd0444c25', + 'pass' => getenv('EMAILTEMPLATE_BRIDGE_DB_PASS') ?: '/7ü9+§ÄfkiQvGPr§2Op7', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], + ], + 'tables_allow' => [], // optional whitelist: ['customers', 'orders'] +]; + +$localOverride = __DIR__ . '/emailtemplate.bridge.conf.php'; +if (is_file($localOverride)) { + $override = include $localOverride; + if (is_array($override)) { + $bridgeConfig = array_replace_recursive($bridgeConfig, $override); + } +} + +function bridgeRespond($payload, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store, max-age=0'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function bridgeRequireToken(array $config): void +{ + $expected = (string)($config['token'] ?? ''); + if ($expected === '') { + bridgeRespond(['ok' => false, 'error' => 'Bridge token not configured'], 500); + } + + $provided = null; + if (!empty($_SERVER['HTTP_AUTHORIZATION']) && stripos($_SERVER['HTTP_AUTHORIZATION'], 'Bearer ') === 0) { + $provided = trim(substr($_SERVER['HTTP_AUTHORIZATION'], 7)); + } elseif (!empty($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN'])) { + $provided = trim($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN']); + } elseif (isset($_GET['token'])) { + $provided = (string)$_GET['token']; + } elseif (isset($_POST['token'])) { + $provided = (string)$_POST['token']; + } + + if (!$provided || !hash_equals($expected, $provided)) { + bridgeRespond(['ok' => false, 'error' => 'Unauthorized'], 403); + } +} + +function bridgeDb(array $config): PDO +{ + static $pdo = null; + if ($pdo instanceof PDO) { + return $pdo; + } + + try { + $pdo = new PDO( + $config['db']['dsn'], + $config['db']['user'], + $config['db']['pass'], + $config['db']['options'] + ); + } catch (Throwable $e) { + bridgeRespond(['ok' => false, 'error' => 'DB connection failed', 'detail' => $e->getMessage()], 500); + } + + return $pdo; +} + +bridgeRequireToken($bridgeConfig); + +$action = strtolower((string)($_GET['action'] ?? $_POST['action'] ?? 'schema')); + +if ($action === 'ping') { + bridgeRespond(['ok' => true, 'time' => date(DATE_ATOM)]); +} + +if ($action !== 'schema') { + bridgeRespond(['ok' => false, 'error' => 'Unknown action'], 404); +} + +$pdo = bridgeDb($bridgeConfig); + +try { + $dbName = ''; + if (preg_match('/dbname=([^;]+)/i', $bridgeConfig['db']['dsn'], $m)) { + $dbName = $m[1]; + } + + $tablesStmt = $pdo->query('SHOW FULL TABLES'); + $tables = []; + $whitelist = []; + if (!empty($bridgeConfig['tables_allow']) && is_array($bridgeConfig['tables_allow'])) { + foreach ($bridgeConfig['tables_allow'] as $tbl) { + if (is_string($tbl) && $tbl !== '') { + $whitelist[strtolower($tbl)] = true; + } + } + } + while ($row = $tablesStmt->fetch(PDO::FETCH_NUM)) { + $tableName = $row[0]; + if ($tableName === null) { + continue; + } + if ($whitelist && empty($whitelist[strtolower($tableName)])) { + continue; + } + + $columnsStmt = $pdo->prepare( + 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + ORDER BY ORDINAL_POSITION' + ); + $columnsStmt->execute([ + ':schema' => $dbName ?: $pdo->query('SELECT DATABASE()')->fetchColumn(), + ':table' => $tableName, + ]); + + $columns = []; + foreach ($columnsStmt as $col) { + $columns[] = [ + 'name' => $col['COLUMN_NAME'], + 'type' => $col['DATA_TYPE'], + 'nullable' => ($col['IS_NULLABLE'] === 'YES'), + 'default' => $col['COLUMN_DEFAULT'], + 'key' => $col['COLUMN_KEY'], + 'extra' => $col['EXTRA'], + 'placeholder'=> strtoupper($tableName) . '__' . strtoupper($col['COLUMN_NAME']), + ]; + } + + $tables[] = [ + 'name' => $tableName, + 'columns' => $columns, + ]; + } + + bridgeRespond([ + 'ok' => true, + 'tables' => $tables, + 'fetched' => date(DATE_ATOM), + ]); +} catch (Throwable $e) { + bridgeRespond(['ok' => false, 'error' => 'Schema fetch failed', 'detail' => $e->getMessage()], 500); +} \ No newline at end of file diff --git a/public/test/emailtemplate_sender.php b/public/test/emailtemplate_sender.php new file mode 100644 index 0000000..6daf433 --- /dev/null +++ b/public/test/emailtemplate_sender.php @@ -0,0 +1,255 @@ + getenv('EMAILTEMPLATE_SENDER_TOKEN') ?: 'DWFE3f5NQTZH5phtVrOWRf4BL3bmrrPRk1LUkorAwPwjttfcFgzkhe4LtL1n3jYA', + 'template_api' => [ + 'base_url' => getenv('EMAILTEMPLATE_API_BASE') ?: 'https://staging.emailtemplate.it/external/render', + 'token' => getenv('EMAILTEMPLATE_API_TOKEN') ?: 'qYLV9OLOJ48WPs0CLrnOWpkok1o1wRN_kGsdMVzumzPEYQP-rHxhnn0YlSWGW5hH', + 'timeout' => 15, + ], + 'db' => [ + 'dsn' => getenv('EMAILTEMPLATE_LOCAL_DSN') ?: 'mysql:host=127.0.0.1;dbname=d0444c25;charset=utf8mb4', + 'user' => getenv('EMAILTEMPLATE_LOCAL_DB_USER') ?: 'root', + 'pass' => getenv('EMAILTEMPLATE_LOCAL_DB_PASS') ?: '/7ü9+§ÄfkiQvGPr§2Op7', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], + 'enabled' => true, + ], + 'mail' => [ + 'from_email' => 'no-reply@example.com', + 'from_name' => 'EmailTemplate Sender', + 'transport' => 'mail', // aktuell nur mail() + ], +]; + +$localSenderOverride = __DIR__ . '/emailtemplate.sender.conf.php'; +if (is_file($localSenderOverride)) { + $override = include $localSenderOverride; + if (is_array($override)) { + $senderConfig = array_replace_recursive($senderConfig, $override); + } +} + +function senderRespond($payload, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store, max-age=0'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function senderRequireToken(array $config): void +{ + $expected = (string)($config['token'] ?? ''); + if ($expected === '') { + senderRespond(['ok' => false, 'error' => 'Sender token not configured'], 500); + } + + $provided = null; + if (!empty($_SERVER['HTTP_AUTHORIZATION']) && stripos($_SERVER['HTTP_AUTHORIZATION'], 'Bearer ') === 0) { + $provided = trim(substr($_SERVER['HTTP_AUTHORIZATION'], 7)); + } elseif (!empty($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN'])) { + $provided = trim($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN']); + } elseif (isset($_POST['token'])) { + $provided = (string)$_POST['token']; + } + + if (!$provided || !hash_equals($expected, $provided)) { + senderRespond(['ok' => false, 'error' => 'Unauthorized'], 403); + } +} + +function senderInput(): array +{ + $raw = file_get_contents('php://input'); + if ($raw !== false && trim($raw) !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $decoded; + } + } + + if (!empty($_POST)) { + return $_POST; + } + + return []; +} + +function senderDb(array $config): ?PDO +{ + if (empty($config['db']['enabled'])) { + return null; + } + + static $pdo = null; + if ($pdo instanceof PDO) { + return $pdo; + } + + try { + $pdo = new PDO( + $config['db']['dsn'], + $config['db']['user'], + $config['db']['pass'], + $config['db']['options'] + ); + } catch (Throwable $e) { + senderRespond(['ok' => false, 'error' => 'DB connection failed', 'detail' => $e->getMessage()], 500); + } + + return $pdo; +} + +function resolvePlaceholderValue($definition, ?PDO $pdo) +{ + if (is_scalar($definition)) { + return $definition; + } + if (!is_array($definition)) { + return null; + } + + if (array_key_exists('value', $definition)) { + return $definition['value']; + } + + if (($definition['source'] ?? '') === 'db') { + if (!$pdo) { + throw new RuntimeException('Database access disabled'); + } + $table = $definition['table'] ?? null; + $column = $definition['column'] ?? null; + $where = $definition['where'] ?? []; + if (!$table || !$column || !is_array($where) || !$where) { + throw new InvalidArgumentException('Invalid DB placeholder definition'); + } + $conditions = []; + $params = []; + foreach ($where as $key => $value) { + $param = ':w_' . preg_replace('/[^a-z0-9_]/i', '_', $key); + $conditions[] = sprintf('`%s` = %s', str_replace('`', '', $key), $param); + $params[$param] = $value; + } + $sql = sprintf( + 'SELECT `%s` FROM `%s` WHERE %s LIMIT 1', + str_replace('`', '', $column), + str_replace('`', '', $table), + implode(' AND ', $conditions) + ); + $stmt = $pdo->prepare($sql); + foreach ($params as $param => $value) { + $stmt->bindValue($param, $value); + } + $stmt->execute(); + return $stmt->fetchColumn(); + } + + return null; +} + +function fetchTemplateHtml(array $config, array $payload): array +{ + $apiUrl = $config['template_api']['base_url']; + $apiToken = $config['template_api']['token']; + if (!$apiUrl || !$apiToken) { + senderRespond(['ok' => false, 'error' => 'Template API not configured'], 500); + } + + $body = $payload; + $body['token'] = $apiToken; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'timeout' => (int)($config['template_api']['timeout'] ?? 15), + 'content' => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ]); + + $response = @file_get_contents($apiUrl, false, $context); + if ($response === false) { + senderRespond(['ok' => false, 'error' => 'Template API unreachable'], 502); + } + + $decoded = json_decode($response, true); + if (!is_array($decoded) || !empty($decoded['ok']) === false) { + senderRespond(['ok' => false, 'error' => 'Template API error', 'detail' => $decoded], 502); + } + + return $decoded; +} + +function sendMail(array $config, string $html, string $subject, array $payload): void +{ + $headers = []; + if (!empty($config['mail']['from_email'])) { + $fromName = $config['mail']['from_name'] ?? ''; + if ($fromName !== '') { + $headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $config['mail']['from_email']); + } else { + $headers[] = 'From: ' . $config['mail']['from_email']; + } + } + $headers[] = 'Content-Type: text/html; charset=utf-8'; + + $to = $payload['to'] ?? ''; + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + senderRespond(['ok' => false, 'error' => 'Invalid recipient'], 422); + } + + if (!mail($to, $subject, $html, implode("\r\n", $headers))) { + senderRespond(['ok' => false, 'error' => 'mail() transport failed'], 500); + } +} + +senderRequireToken($senderConfig); +$input = senderInput(); +$pdo = senderDb($senderConfig); + +try { + $placeholders = $input['placeholders'] ?? []; + if (!is_array($placeholders)) { + senderRespond(['ok' => false, 'error' => 'Invalid placeholders'], 422); + } + $resolved = []; + foreach ($placeholders as $key => $definition) { + $resolved[$key] = resolvePlaceholderValue($definition, $pdo); + } + $input['placeholders'] = $resolved; + + $template = fetchTemplateHtml($senderConfig, $input); + if (empty($template['ok']) || empty($template['html'])) { + senderRespond(['ok' => false, 'error' => 'Template API returned no HTML'], 502); + } + + $subject = $input['subject'] ?? ($template['subject'] ?? 'EmailTemplate'); + sendMail($senderConfig, $template['html'], $subject, $input); + + senderRespond(['ok' => true]); +} catch (Throwable $e) { + senderRespond(['ok' => false, 'error' => 'Sender failure', 'detail' => $e->getMessage()], 500); +} \ No newline at end of file