Central support desk for the Oumta project. Tickets arrive through a
self-hostable support-relay (public form, SMTP out), hyprcrd pulls
them in as cards, replies go back out through the relay, and KB
fragments are plain hyprcrd cards so the board + search + tags
machinery handles them for free.
support-relay/ (standalone PHP/SQLite service)
- Public form + submit endpoint with IP rate-limit, honeypot, and an
email-matched reply path so users can follow up by typing their
OUMTA-n issue id — mismatched email falls back to a fresh ticket
so a hostile submitter can't hijack someone else's thread.
- One-shot setup wizard configures branding, issue-id prefix, public
URL, mail footer template, SMTP creds (stored AES-256-GCM at rest),
and generates the X-Support-Key API token shown exactly once.
- Hyprcrd-facing API: GET /api/hyprcrd/tickets?since=<iso>, POST
…/reply (sends via SMTP, flips status to answered), PATCH
…/status, DELETE, GET /ping, plus GET/POST /api/hyprcrd/settings
exposing brand/public_url/mail_footer for post-setup edits.
- Minimal dependency-free SMTP client covering STARTTLS/TLS/none,
AUTH LOGIN, RFC 2047 subject encoding, dot-stuffing. Extended to
emit multipart/alternative (plain+HTML) + multipart/related so
outbound mails carry the branded logo via Content-ID embedding;
plain-text fallback stays intact for older clients.
- Mail footer template supports {brand}, {issue_id}, {public_url}
placeholders. Lines with an empty placeholder are dropped, so the
"Reply via …" line vanishes cleanly when no public URL is set.
- Favicon on all public pages. Logo (SVG by default, PNG preferred
when both exist) picked up from logo2026col.* at send time.
- Docker: Dockerfile + docker-compose.yml for a stand-alone test
container, plus a support-relay service in the main compose
(profile "support") so hyprcrd can reach it at http://support-relay
without host-networking shenanigans.
public/plugins/support (hyprcrd client)
- Full-screen Support overlay: ticket list (status-filterable),
active ticket thread, reply composer with KB-fragment search +
click-to-insert, "Save as fragment" button, "Close ticket" shortcut.
- KB fragments are regular cards in a configurable bucket. The
composer just adds a fast search-and-insert path on top.
- Sync-tick hook auto-polls the relay alongside the existing CalDAV
+ relay-mail cycle.
- Settings tab in the main overlay: relay URL, API key, inbox / KB
bucket pickers, connection test, and a "Relay config" subsection
that round-trips to /api/hyprcrd/settings so the mail footer and
branding can be edited without touching the relay's DB.
- Local cache tables support_tickets + support_replies mirror the
relay's authoritative data keyed by relay_id.
Tooling
- dev.sh now opts into both relay + support profiles.
- dev-down.sh wipes support-relay volumes and bind-mount data for a
clean re-setup. Hyprcrd + relay data is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
6.5 KiB
PHP
140 lines
6.5 KiB
PHP
<?php
|
|
// hyprcrd-facing API. Pulls new/updated tickets, pushes outbound replies
|
|
// (which the relay then sends via SMTP), syncs status changes. Shared
|
|
// secret auth via X-Support-Key.
|
|
|
|
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
// All /api/hyprcrd/* paths require the API key. Guard once up front.
|
|
if (str_starts_with($route, '/api/hyprcrd/')) {
|
|
if ($isSetup) supportRelayJson(['error' => '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]);
|
|
}
|