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>
101 lines
5.2 KiB
PHP
101 lines
5.2 KiB
PHP
<?php
|
|
// Public-facing routes used by the form JS: submit a ticket, check
|
|
// ticket status. No auth — rate-limited + honeypot + issue-ID-email
|
|
// guard against abuse.
|
|
|
|
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
if ($route === '/api/submit' && $method === 'POST') {
|
|
if ($isSetup) supportRelayJson(['error' => '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(),
|
|
]);
|
|
}
|