'relay not configured'], 503); if (!supportRelayApiAuth($db)) supportRelayJson(['error' => 'unauthorized'], 401); } // Poll: tickets updated after `since` (ISO timestamp). Client stores // the max updated_at it sees and sends that as `since` next time. if ($route === '/api/hyprcrd/tickets' && $method === 'GET') { $since = trim((string)($_GET['since'] ?? '')); $sql = 'SELECT * FROM tickets'; $params = []; if ($since !== '') { $sql .= ' WHERE updated_at > :s'; $params['s'] = $since; } $sql .= ' ORDER BY updated_at ASC LIMIT 500'; $stmt = $db->prepare($sql); $stmt->execute($params); $tickets = $stmt->fetchAll(); $out = []; $ids = []; foreach ($tickets as $t) { $t['issue_id'] = supportRelayIssueId($db, (int)$t['id']); $out[] = $t; $ids[] = (int)$t['id']; } $replies = []; if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $rstmt = $db->prepare("SELECT id, ticket_id, direction, body, sent_at, created_at FROM replies WHERE ticket_id IN ($ph) ORDER BY ticket_id, created_at"); $rstmt->execute($ids); foreach ($rstmt->fetchAll() as $r) { $tid = (int)$r['ticket_id']; if (!isset($replies[$tid])) $replies[$tid] = []; $replies[$tid][] = $r; } } foreach ($out as &$t) { $t['replies'] = $replies[(int)$t['id']] ?? []; } unset($t); supportRelayJson(['tickets' => $out]); } // Push an outgoing reply. Relay generates the RFC 5322 message, sends // via SMTP, logs the reply. Status flips to "answered" on success. if (preg_match('#^/api/hyprcrd/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') { $id = (int)$m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); if ($body === '') supportRelayJson(['error' => 'body required'], 400); $t = $db->prepare('SELECT * FROM tickets WHERE id = :id'); $t->execute(['id' => $id]); $ticket = $t->fetch(); if (!$ticket) supportRelayJson(['error' => 'ticket not found'], 404); $smtp = supportRelaySmtpConfig($db, $MASTER_KEY); if ($smtp['host'] === '' || $smtp['from'] === '') supportRelayJson(['error' => 'SMTP not configured'], 500); $brand = supportRelayGet($db, 'brand', 'Support'); $issueId = supportRelayIssueId($db, $id); $subject = ($ticket['subject'] ?: '(no subject)') . ' [' . $issueId . ']'; $message = $body . "\n\n" . supportRelayRenderFooter($db, $issueId); $extraHeaders = ['Subject' => $subject, 'X-Support-Ticket' => $issueId]; if (!empty($ticket['message_id'])) $extraHeaders['In-Reply-To'] = '<' . $ticket['message_id'] . '>'; $client = new SmtpClient($smtp); $res = $client->send( $ticket['email'], $smtp['from_name'], $smtp['from'], $message, $extraHeaders, supportRelayMailOptions($message, $brand) ); if (!$res['ok']) supportRelayJson(['error' => 'send failed: ' . $res['error']], 500); $db->prepare('INSERT INTO replies (ticket_id, direction, body, sent_at) VALUES (:t, "out", :b, datetime("now"))') ->execute(['t' => $id, 'b' => $body]); $updateMid = empty($ticket['message_id']) && !empty($res['message_id']); if ($updateMid) { $db->prepare('UPDATE tickets SET status = "answered", updated_at = datetime("now"), last_reply_at = datetime("now"), message_id = :mid WHERE id = :id') ->execute(['id' => $id, 'mid' => $res['message_id']]); } else { $db->prepare('UPDATE tickets SET status = "answered", updated_at = datetime("now"), last_reply_at = datetime("now") WHERE id = :id') ->execute(['id' => $id]); } supportRelayJson(['ok' => true, 'message_id' => $res['message_id'] ?? null]); } // Status change (open / answered / closed) if (preg_match('#^/api/hyprcrd/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') { $id = (int)$m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $status = trim((string)($input['status'] ?? '')); if (!in_array($status, ['open', 'answered', 'closed'], true)) supportRelayJson(['error' => 'invalid status'], 400); $db->prepare('UPDATE tickets SET status = :s, updated_at = datetime("now") WHERE id = :id') ->execute(['s' => $status, 'id' => $id]); supportRelayJson(['ok' => true]); } // Delete a ticket — rarely needed but useful for cleanup if (preg_match('#^/api/hyprcrd/tickets/(\d+)$#', $route, $m) && $method === 'DELETE') { $id = (int)$m[1]; $db->prepare('DELETE FROM tickets WHERE id = :id')->execute(['id' => $id]); supportRelayJson(['ok' => true]); } // Basic health check so hyprcrd can verify connectivity during settings // config without pulling a real payload. if ($route === '/api/hyprcrd/ping' && $method === 'GET') { supportRelayJson(['ok' => true, 'brand' => supportRelayGet($db, 'brand', 'Support')]); } // Subset of editable relay settings that the admin (via hyprcrd) is // allowed to change post-setup. SMTP credentials + API key stay locked // to the setup wizard — editing those from an authenticated hyprcrd is // out of scope for V1 and would need stricter handling. if ($route === '/api/hyprcrd/settings' && $method === 'GET') { supportRelayJson([ 'brand' => supportRelayGet($db, 'brand', 'Support'), 'public_url' => supportRelayGet($db, 'public_url', ''), 'mail_footer' => supportRelayGet($db, 'mail_footer', '') ?: SUPPORT_RELAY_FOOTER_DEFAULT, 'default_footer' => SUPPORT_RELAY_FOOTER_DEFAULT, ]); } if ($route === '/api/hyprcrd/settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; if (array_key_exists('brand', $input)) supportRelaySet($db, 'brand', trim((string)$input['brand']) ?: 'Support'); if (array_key_exists('public_url', $input)) supportRelaySet($db, 'public_url', rtrim((string)$input['public_url'], '/')); if (array_key_exists('mail_footer', $input)) { $v = (string)$input['mail_footer']; // Empty string falls back to the built-in default at render time. supportRelaySet($db, 'mail_footer', $v === '' ? null : $v); } supportRelayJson(['ok' => true]); }