'relay not configured'], 503); if (supportRelayRateLimited($db, 'submit', 5, 60)) supportRelayJson(['error' => 'Too many requests. Try again in an hour.'], 429); $input = json_decode(file_get_contents('php://input'), true) ?: []; // Honeypot: a hidden field browsers won't fill but bots will. If it's // set, pretend success and drop the submission silently. if (!empty($input['website'])) supportRelayJson(['ok' => true, 'issue_id' => 'OUMTA-0']); $email = trim((string)($input['email'] ?? '')); $subject = trim((string)($input['subject'] ?? '')); $body = trim((string)($input['body'] ?? '')); $issueHint = trim((string)($input['issue_id'] ?? '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'valid email required'], 400); if (mb_strlen($body) < 10) supportRelayJson(['error' => 'message too short'], 400); if (mb_strlen($body) > 10000) supportRelayJson(['error' => 'message too long (max 10k)'], 400); if (mb_strlen($subject) > 200) supportRelayJson(['error' => 'subject too long'], 400); // If the user claims to reply to an existing ticket, verify the email // matches before appending. Mismatch → treat it as a brand-new ticket // so we don't ghost-route hostile submissions to someone else's thread. $targetId = $issueHint !== '' ? supportRelayParseIssueId($db, $issueHint) : null; if ($targetId) { $t = $db->prepare('SELECT id, email, subject FROM tickets WHERE id = :id'); $t->execute(['id' => $targetId]); $row = $t->fetch(); if ($row && strtolower($row['email']) === strtolower($email)) { $db->prepare('INSERT INTO replies (ticket_id, direction, body) VALUES (:t, "in", :b)') ->execute(['t' => $targetId, 'b' => $body]); $db->prepare('UPDATE tickets SET status = "open", updated_at = datetime("now"), last_reply_at = datetime("now") WHERE id = :id') ->execute(['id' => $targetId]); supportRelayJson([ 'ok' => true, 'issue_id' => supportRelayIssueId($db, $targetId), 'appended' => true, ]); } // Wrong email → fall through to new-ticket path. Don't leak that // the id existed. } // New ticket $db->prepare('INSERT INTO tickets (email, subject, body, status) VALUES (:e, :s, :b, "open")') ->execute(['e' => strtolower($email), 's' => $subject, 'b' => $body]); $id = (int)$db->lastInsertId(); $db->prepare('INSERT INTO replies (ticket_id, direction, body) VALUES (:t, "in", :b)') ->execute(['t' => $id, 'b' => $body]); $issueId = supportRelayIssueId($db, $id); // Confirmation mail — best-effort, swallow SMTP failures so the user // still gets their issue id even if the mail gateway is misbehaving. $smtp = supportRelaySmtpConfig($db, $MASTER_KEY); if ($smtp['host'] !== '' && $smtp['from'] !== '') { $brand = supportRelayGet($db, 'brand', 'Support'); $body = "Hi,\n\nwe received your support request.\n\nIssue: " . $issueId . "\nSubject: " . ($subject ?: '(no subject)') . "\n\n" . supportRelayRenderFooter($db, $issueId); $client = new SmtpClient($smtp); $brandName = supportRelayGet($db, 'brand', 'Support'); $client->send( $email, $smtp['from_name'], $smtp['from'], $body, ['Subject' => '[' . $issueId . '] ' . ($subject !== '' ? $subject : 'Support request received')], supportRelayMailOptions($body, $brandName) ); } supportRelayJson(['ok' => true, 'issue_id' => $issueId]); } if (preg_match('#^/api/ticket/([A-Za-z0-9-]+)$#', $route, $m) && $method === 'GET') { if ($isSetup) supportRelayJson(['error' => 'relay not configured'], 503); $email = trim((string)($_GET['email'] ?? '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'email required'], 400); $id = supportRelayParseIssueId($db, $m[1]); if (!$id) supportRelayJson(['error' => 'not found'], 404); $t = $db->prepare('SELECT id, email, subject, status, created_at, updated_at FROM tickets WHERE id = :id'); $t->execute(['id' => $id]); $row = $t->fetch(); if (!$row || strtolower($row['email']) !== strtolower($email)) supportRelayJson(['error' => 'not found'], 404); // Public status view: only the outbound replies (what we answered). // Internal notes / inbound replay stays hidden. $reps = $db->prepare('SELECT body, created_at FROM replies WHERE ticket_id = :id AND direction = "out" ORDER BY created_at ASC'); $reps->execute(['id' => $id]); supportRelayJson([ 'issue_id' => supportRelayIssueId($db, $id), 'subject' => $row['subject'], 'status' => $row['status'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], 'replies' => $reps->fetchAll(), ]); }