commit 4d87d1c199e2d4681594e57b66649aa7e1287e70 Author: Flatlogic Bot Date: Thu Dec 18 14:30:50 2025 +0000 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e427ff3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*/node_modules/ +*/build/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..e2bbc23 --- /dev/null +++ b/.htaccess @@ -0,0 +1,18 @@ +DirectoryIndex index.php index.html +Options -Indexes +Options -MultiViews + +RewriteEngine On + +# 0) Serve existing files/directories as-is +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +# 1) Internal map: /page or /page/ -> /page.php (if such PHP file exists) +RewriteCond %{REQUEST_FILENAME}.php -f +RewriteRule ^(.+?)/?$ $1.php [L] + +# 2) Optional: strip trailing slash for non-directories (keeps .php links working) +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.+)/$ $1 [R=301,L] diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php new file mode 100644 index 0000000..d428248 --- /dev/null +++ b/ai/LocalAIApi.php @@ -0,0 +1,493 @@ + [ +// ['role' => 'system', 'content' => 'You are a helpful assistant.'], +// ['role' => 'user', 'content' => 'Tell me a bedtime story.'], +// ], +// ]); +// if (!empty($response['success'])) { +// // response['data'] contains full payload, e.g.: +// // { +// // "id": "resp_xxx", +// // "status": "completed", +// // "output": [ +// // {"type": "reasoning", "summary": []}, +// // {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]} +// // ] +// // } +// $decoded = LocalAIApi::decodeJsonFromResponse($response); // or inspect $response['data'] / extractText(...) +// } +// Poll settings override: +// LocalAIApi::createResponse($payload, ['poll_interval' => 5, 'poll_timeout' => 300]); + +class LocalAIApi +{ + /** @var array|null */ + private static ?array $configCache = null; + + /** + * Signature compatible with the OpenAI Responses API. + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.). + * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid). + * @return array{ + * success:bool, + * status?:int, + * data?:mixed, + * error?:string, + * response?:mixed, + * message?:string + * } + */ + public static function createResponse(array $params, array $options = []): array + { + $cfg = self::config(); + $payload = $params; + + if (empty($payload['input']) || !is_array($payload['input'])) { + return [ + 'success' => false, + 'error' => 'input_missing', + 'message' => 'Parameter "input" is required and must be an array.', + ]; + } + + if (!isset($payload['model']) || $payload['model'] === '') { + $payload['model'] = $cfg['default_model']; + } + + $initial = self::request($options['path'] ?? null, $payload, $options); + if (empty($initial['success'])) { + return $initial; + } + + // Async flow: if backend returns ai_request_id, poll status until ready + $data = $initial['data'] ?? null; + if (is_array($data) && isset($data['ai_request_id'])) { + $aiRequestId = $data['ai_request_id']; + $pollTimeout = isset($options['poll_timeout']) ? (int) $options['poll_timeout'] : 300; // seconds + $pollInterval = isset($options['poll_interval']) ? (int) $options['poll_interval'] : 5; // seconds + return self::awaitResponse($aiRequestId, [ + 'timeout' => $pollTimeout, + 'interval' => $pollInterval, + 'headers' => $options['headers'] ?? [], + 'timeout_per_call' => $options['timeout'] ?? null, + ]); + } + + return $initial; + } + + /** + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array + */ + public static function create_response(array $params, array $options = []): array + { + return self::createResponse($params, $options); + } + + /** + * Perform a raw request to the AI proxy. + * + * @param string $path Endpoint (may be an absolute URL). + * @param array $payload JSON payload. + * @param array $options Additional request options. + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + $cfg = self::config(); + + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting AI request.', + ]; + } + + $defaultPath = $cfg['responses_path'] ?? null; + $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); + if (empty($resolvedPath)) { + return [ + 'success' => false, + 'error' => 'project_id_missing', + 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', + ]; + } + + $url = self::buildUrl($resolvedPath, $cfg['base_url']); + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + $headers[] = $projectHeader . ': ' . $projectUuid; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { + $payload['project_uuid'] = $projectUuid; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + return self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls); + } + + /** + * Poll AI request status until ready or timeout. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function awaitResponse($aiRequestId, array $options = []): array + { + $cfg = self::config(); + + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : 300; // seconds + $interval = isset($options['interval']) ? (int) $options['interval'] : 5; // seconds + if ($interval <= 0) { + $interval = 5; + } + $perCallTimeout = isset($options['timeout_per_call']) ? (int) $options['timeout_per_call'] : null; + + $deadline = time() + max($timeout, $interval); + $headers = $options['headers'] ?? []; + + while (true) { + $statusResp = self::fetchStatus($aiRequestId, [ + 'headers' => $headers, + 'timeout' => $perCallTimeout, + ]); + if (!empty($statusResp['success'])) { + $data = $statusResp['data'] ?? []; + if (is_array($data)) { + $statusValue = $data['status'] ?? null; + if ($statusValue === 'success') { + return [ + 'success' => true, + 'status' => 200, + 'data' => $data['response'] ?? $data, + ]; + } + if ($statusValue === 'failed') { + return [ + 'success' => false, + 'status' => 500, + 'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed', + 'data' => $data, + ]; + } + } + } else { + return $statusResp; + } + + if (time() >= $deadline) { + return [ + 'success' => false, + 'error' => 'timeout', + 'message' => 'Timed out waiting for AI response.', + ]; + } + sleep($interval); + } + } + + /** + * Fetch status for queued AI request. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function fetchStatus($aiRequestId, array $options = []): array + { + $cfg = self::config(); + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting status check.', + ]; + } + + $statusPath = self::resolveStatusPath($aiRequestId, $cfg); + $url = self::buildUrl($statusPath, $cfg['base_url']); + + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + $headers = [ + 'Accept: application/json', + $projectHeader . ': ' . $projectUuid, + ]; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls); + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $response Result of LocalAIApi::createResponse|request. + * @return string + */ + public static function extractText(array $response): string + { + $payload = $response['data'] ?? $response; + if (!is_array($payload)) { + return ''; + } + + if (!empty($payload['output']) && is_array($payload['output'])) { + $combined = ''; + foreach ($payload['output'] as $item) { + if (!isset($item['content']) || !is_array($item['content'])) { + continue; + } + foreach ($item['content'] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { + $combined .= $block['text']; + } + } + } + if ($combined !== '') { + return $combined; + } + } + + if (!empty($payload['choices'][0]['message']['content'])) { + return (string) $payload['choices'][0]['message']['content']; + } + + return ''; + } + + /** + * Attempt to decode JSON emitted by the model (handles markdown fences). + * + * @param array $response + * @return array|null + */ + public static function decodeJsonFromResponse(array $response): ?array + { + $text = self::extractText($response); + if ($text === '') { + return null; + } + + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + $stripped = preg_replace('/^```json|```$/m', '', trim($text)); + if ($stripped !== null && $stripped !== $text) { + $decoded = json_decode($stripped, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } + + /** + * Load configuration from ai/config.php. + * + * @return array + */ + private static function config(): array + { + if (self::$configCache === null) { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new RuntimeException('AI config file not found: ai/config.php'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new RuntimeException('Invalid AI config format: expected array'); + } + self::$configCache = $cfg; + } + + return self::$configCache; + } + + /** + * Build an absolute URL from base_url and a path. + */ + private static function buildUrl(string $path, string $baseUrl): string + { + $trimmed = trim($path); + if ($trimmed === '') { + return $baseUrl; + } + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + if ($trimmed[0] === '/') { + return $baseUrl . $trimmed; + } + return $baseUrl . '/' . $trimmed; + } + + /** + * Resolve status path based on configured responses_path and ai_request_id. + * + * @param int|string $aiRequestId + * @param array $cfg + * @return string + */ + private static function resolveStatusPath($aiRequestId, array $cfg): string + { + $basePath = $cfg['responses_path'] ?? ''; + $trimmed = rtrim($basePath, '/'); + if ($trimmed === '') { + return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status'; + } + if (substr($trimmed, -11) !== '/ai-request') { + $trimmed .= '/ai-request'; + } + return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status'; + } + + /** + * Shared CURL sender for GET/POST requests. + * + * @param string $url + * @param string $method + * @param string|null $body + * @param array $headers + * @param int $timeout + * @param bool $verifyTls + * @return array + */ + private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): array + { + if (!function_exists('curl_init')) { + return [ + 'success' => false, + 'error' => 'curl_missing', + 'message' => 'PHP cURL extension is missing. Install or enable it on the VM.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); + curl_setopt($ch, CURLOPT_FAILONERROR, false); + + $upper = strtoupper($method); + if ($upper === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); + } else { + curl_setopt($ch, CURLOPT_HTTPGET, true); + } + + $responseBody = curl_exec($ch); + if ($responseBody === false) { + $error = curl_error($ch) ?: 'Unknown cURL error'; + curl_close($ch); + return [ + 'success' => false, + 'error' => 'curl_error', + 'message' => $error, + ]; + } + + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = null; + if ($responseBody !== '' && $responseBody !== null) { + $decoded = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $decoded = null; + } + } + + if ($status >= 200 && $status < 300) { + return [ + 'success' => true, + 'status' => $status, + 'data' => $decoded ?? $responseBody, + ]; + } + + $errorMessage = 'AI proxy request failed'; + if (is_array($decoded)) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; + } elseif (is_string($responseBody) && $responseBody !== '') { + $errorMessage = $responseBody; + } + + return [ + 'success' => false, + 'status' => $status, + 'error' => $errorMessage, + 'response' => $decoded ?? $responseBody, + ]; + } +} + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} diff --git a/ai/config.php b/ai/config.php new file mode 100644 index 0000000..c890698 --- /dev/null +++ b/ai/config.php @@ -0,0 +1,52 @@ + $baseUrl, + 'responses_path' => $responsesPath, + 'project_id' => $projectId, + 'project_uuid' => $projectUuid, + 'project_header' => 'project-uuid', + 'default_model' => 'gpt-5-mini', + 'timeout' => 30, + 'verify_tls' => true, +]; diff --git a/db/config.php b/db/config.php new file mode 100644 index 0000000..4c49960 --- /dev/null +++ b/db/config.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + return $pdo; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..7205f3d --- /dev/null +++ b/index.php @@ -0,0 +1,150 @@ + + + + + + + New Style + + + + + + + + + + + + + + + + + + + + + +
+
+

Analyzing your requirements and generating your website…

+
+ Loading… +
+

AI is collecting your requirements and applying the first changes.

+

This page will update automatically as the plan is implemented.

+

Runtime: PHP — UTC

+
+
+
+ Page updated: (UTC) +
+ + diff --git a/mail/MailService.php b/mail/MailService.php new file mode 100644 index 0000000..d801067 --- /dev/null +++ b/mail/MailService.php @@ -0,0 +1,235 @@ + false, 'error' => 'PHPMailer not available' ]; + } + + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost'); + $fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App'); + $mail->setFrom($fromEmail, $fromName); + if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($opts['reply_to']); + } elseif (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Recipients + $toList = []; + if ($to) { + if (is_string($to)) $toList = array_map('trim', explode(',', $to)); + elseif (is_array($to)) $toList = $to; + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { $mail->addAddress($addr); $added++; } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) $mail->addCC($cc); } + foreach ((array)($opts['bcc'] ?? []) as $bcc){ if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) $mail->addBCC($bcc); } + + // Optional DKIM + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlBody; + $mail->AltBody = $textBody ?? strip_tags($htmlBody); + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + private static function loadConfig(): array + { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new \RuntimeException('Invalid mail config format: expected array'); + } + return $cfg; + } + + // Send a contact message + // $to can be: a single email string, a comma-separated list, an array of emails, or null (fallback to MAIL_TO/MAIL_FROM) + public static function sendContactMessage(string $name, string $email, string $message, $to = null, string $subject = 'New contact form') + { + $cfg = self::loadConfig(); + + // Try Composer autoload if available (for PHPMailer) + $autoload = __DIR__ . '/../vendor/autoload.php'; + if (file_exists($autoload)) { + require_once $autoload; + } + // Fallback to system-wide PHPMailer (installed via apt: libphp-phpmailer) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + // Debian/Ubuntu package layout (libphp-phpmailer) + @require_once 'libphp-phpmailer/autoload.php'; + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'libphp-phpmailer/src/Exception.php'; + @require_once 'libphp-phpmailer/src/SMTP.php'; + @require_once 'libphp-phpmailer/src/PHPMailer.php'; + } + // Alternative layout (older PHPMailer package names) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/src/Exception.php'; + @require_once 'PHPMailer/src/SMTP.php'; + @require_once 'PHPMailer/src/PHPMailer.php'; + } + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/Exception.php'; + @require_once 'PHPMailer/SMTP.php'; + @require_once 'PHPMailer/PHPMailer.php'; + } + } + + $transport = $cfg['transport'] ?? 'smtp'; + if ($transport === 'smtp' && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + return self::sendViaPHPMailer($cfg, $name, $email, $message, $to, $subject); + } + + // Fallback: attempt native mail() — works only if MTA is configured on the VM + return self::sendViaNativeMail($cfg, $name, $email, $message, $to, $subject); + } + + private static function sendViaPHPMailer(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $cfg['from_email'] ?? 'no-reply@localhost'; + $fromName = $cfg['from_name'] ?? 'App'; + $mail->setFrom($fromEmail, $fromName); + + // Use Reply-To for the user's email to avoid spoofing From + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($email, $name ?: $email); + } + if (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Destination: prefer dynamic recipients ($to), fallback to MAIL_TO; no silent FROM fallback + $toList = []; + if ($to) { + if (is_string($to)) { + // allow comma-separated list + $toList = array_map('trim', explode(',', $to)); + } elseif (is_array($to)) { + $toList = $to; + } + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { + $mail->addAddress($addr); + $added++; + } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + // DKIM (optional) + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeEmail = htmlspecialchars($email, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + $mail->Body = "

Name: {$safeName}

Email: {$safeEmail}


{$safeBody}"; + $mail->AltBody = "Name: {$name}\nEmail: {$email}\n\n{$body}"; + + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + + private static function sendViaNativeMail(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $opts = ['reply_to' => $email]; + $html = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + return self::sendMail($to, $subject, $html, $body, $opts); + } +} diff --git a/mail/config.php b/mail/config.php new file mode 100644 index 0000000..626cca1 --- /dev/null +++ b/mail/config.php @@ -0,0 +1,76 @@ + config array for MailService. + +function env_val(string $key, $default = null) { + $v = getenv($key); + return ($v === false || $v === null || $v === '') ? $default : $v; +} + +// Fallback: if critical vars are missing from process env, try to parse executor/.env +// This helps in web/Apache contexts where .env is not exported. +// Supports simple KEY=VALUE lines; ignores quotes and comments. +function load_dotenv_if_needed(array $keys): void { + $missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === ''); + if (empty($missing)) return; + static $loaded = false; + if ($loaded) return; + $envPath = realpath(__DIR__ . '/../../.env'); // executor/.env + if ($envPath && is_readable($envPath)) { + $lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + foreach ($lines as $line) { + if ($line[0] === '#' || trim($line) === '') continue; + if (!str_contains($line, '=')) continue; + [$k, $v] = array_map('trim', explode('=', $line, 2)); + // Strip potential surrounding quotes + $v = trim($v, "\"' "); + // Do not override existing env + if ($k !== '' && (getenv($k) === false || getenv($k) === '')) { + putenv("{$k}={$v}"); + } + } + $loaded = true; + } +} + +load_dotenv_if_needed([ + 'MAIL_TRANSPORT','SMTP_HOST','SMTP_PORT','SMTP_SECURE','SMTP_USER','SMTP_PASS', + 'MAIL_FROM','MAIL_FROM_NAME','MAIL_REPLY_TO','MAIL_TO', + 'DKIM_DOMAIN','DKIM_SELECTOR','DKIM_PRIVATE_KEY_PATH' +]); + +$transport = env_val('MAIL_TRANSPORT', 'smtp'); +$smtp_host = env_val('SMTP_HOST'); +$smtp_port = (int) env_val('SMTP_PORT', 587); +$smtp_secure = env_val('SMTP_SECURE', 'tls'); // tls | ssl | null +$smtp_user = env_val('SMTP_USER'); +$smtp_pass = env_val('SMTP_PASS'); + +$from_email = env_val('MAIL_FROM', 'no-reply@localhost'); +$from_name = env_val('MAIL_FROM_NAME', 'App'); +$reply_to = env_val('MAIL_REPLY_TO'); + +$dkim_domain = env_val('DKIM_DOMAIN'); +$dkim_selector = env_val('DKIM_SELECTOR'); +$dkim_private_key_path = env_val('DKIM_PRIVATE_KEY_PATH'); + +return [ + 'transport' => $transport, + + // SMTP + 'smtp_host' => $smtp_host, + 'smtp_port' => $smtp_port, + 'smtp_secure' => $smtp_secure, + 'smtp_user' => $smtp_user, + 'smtp_pass' => $smtp_pass, + + // From / Reply-To + 'from_email' => $from_email, + 'from_name' => $from_name, + 'reply_to' => $reply_to, + + // DKIM (optional) + 'dkim_domain' => $dkim_domain, + 'dkim_selector' => $dkim_selector, + 'dkim_private_key_path' => $dkim_private_key_path, +];