v2.0.12 — support channel: relay + hyprcrd plugin

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>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-19 10:41:19 +02:00
1 parent 2d58cd993e
commit 7bce6a3a36
33 files changed
+2915 -10

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
<?php
// Minimal SMTP client. No dependencies, just stream_socket_client +
// STARTTLS. Covers the narrow case this project needs: plain-text
// ASCII/UTF-8 mail, one recipient, AUTH LOGIN. Deliberately does not
// aim to replace PHPMailer — just enough SMTP for outbound replies.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
class SmtpClient {
private array $config;
private $sock;
private array $log = [];
public function __construct(array $config) { $this->config = $config; }
/** Connect, authenticate, send a single mail. Returns
* ['ok' => bool, 'error' => string, 'message_id' => string, 'log' => string[]].
*
* $options:
* - html: optional HTML body. When set, the message goes
* out as multipart/alternative with plain + html.
* - inlineImages: array of ['cid' => 'logo', 'path' => '/abs/path',
* 'mime' => 'image/svg+xml']. Wraps the alternative
* in multipart/related so <img src="cid:logo">
* resolves in the rendered HTML. */
public function send(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders = [], array $options = []): array {
try {
$this->connect();
$this->greet();
$this->authenticate();
$messageId = $this->transmit($to, $fromName, $fromAddr, $body, $extraHeaders, $options);
$this->quit();
return ['ok' => true, 'error' => '', 'message_id' => $messageId, 'log' => $this->log];
} catch (\Throwable $e) {
$this->closeSocket();
return ['ok' => false, 'error' => $e->getMessage(), 'message_id' => '', 'log' => $this->log];
}
}
private function connect(): void {
$host = $this->config['host'];
$port = (int)($this->config['port'] ?? 587);
$enc = $this->config['encryption'] ?? 'starttls';
// Direct TLS ("smtps", usually port 465) opens on a TLS socket
// straight away; STARTTLS opens plaintext then upgrades after EHLO.
$scheme = ($enc === 'tls') ? 'tls://' : '';
$errno = 0; $errstr = '';
$this->sock = @stream_socket_client($scheme . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
if (!$this->sock) throw new \RuntimeException("connect failed: $errstr ($errno)");
stream_set_timeout($this->sock, 15);
$this->expect(220);
}
private function greet(): void {
$me = gethostname() ?: 'localhost';
$this->send_line('EHLO ' . $me);
$banner = $this->expect(250, true);
if (($this->config['encryption'] ?? '') === 'starttls') {
$this->send_line('STARTTLS');
$this->expect(220);
if (!@stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('STARTTLS: crypto negotiation failed');
}
// EHLO again after the upgrade (RFC 3207 requires it)
$this->send_line('EHLO ' . $me);
$this->expect(250, true);
}
}
private function authenticate(): void {
$user = $this->config['user'] ?? '';
$pass = $this->config['password'] ?? '';
if ($user === '') return; // unauthenticated relays are rare but valid
$this->send_line('AUTH LOGIN');
$this->expect(334);
$this->send_line(base64_encode($user));
$this->expect(334);
$this->send_line(base64_encode($pass));
$this->expect(235);
}
private function transmit(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders, array $options = []): string {
$this->send_line('MAIL FROM:<' . $fromAddr . '>');
$this->expect(250);
$this->send_line('RCPT TO:<' . $to . '>');
$this->expect(250);
$this->send_line('DATA');
$this->expect(354);
// Build RFC 5322 message
$host = parse_url('https://' . ($this->config['host'] ?? 'local'), PHP_URL_HOST) ?: 'local';
$messageId = bin2hex(random_bytes(12)) . '@' . $host;
$date = date('r');
$subject = $extraHeaders['Subject'] ?? 'Support reply';
$html = $options['html'] ?? null;
$inline = $options['inlineImages'] ?? [];
// Common top-of-message headers
$topHeaders = [
'Date: ' . $date,
'From: ' . $this->encodeHeader($fromName) . ' <' . $fromAddr . '>',
'To: <' . $to . '>',
'Subject: ' . $this->encodeHeader($subject),
'Message-ID: <' . $messageId . '>',
'MIME-Version: 1.0',
];
foreach ($extraHeaders as $k => $v) {
if ($k === 'Subject') continue;
$topHeaders[] = $k . ': ' . $v;
}
if ($html === null) {
// Plain-text only (original fast path)
$topHeaders[] = 'Content-Type: text/plain; charset=utf-8';
$topHeaders[] = 'Content-Transfer-Encoding: 8bit';
$message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $this->dotStuff($body) . "\r\n.";
$this->send_line($message);
$this->expect(250);
return $messageId;
}
// Multipart. text/plain + text/html alternative, optionally wrapped
// in multipart/related for CID-addressable inline images so the
// HTML version can reference them via <img src="cid:...">.
$altBoundary = 'alt_' . bin2hex(random_bytes(8));
$altParts = [];
$altParts[] = "--{$altBoundary}\r\n"
. "Content-Type: text/plain; charset=utf-8\r\n"
. "Content-Transfer-Encoding: 8bit\r\n\r\n"
. $this->dotStuff($body);
$altParts[] = "--{$altBoundary}\r\n"
. "Content-Type: text/html; charset=utf-8\r\n"
. "Content-Transfer-Encoding: 8bit\r\n\r\n"
. $this->dotStuff($html);
$altBody = implode("\r\n", $altParts) . "\r\n--{$altBoundary}--";
if (!$inline) {
$topHeaders[] = "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\"";
$message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $altBody . "\r\n.";
$this->send_line($message);
$this->expect(250);
return $messageId;
}
$relBoundary = 'rel_' . bin2hex(random_bytes(8));
$relParts = [];
// First related part: the alternative tree (plain + html)
$relParts[] = "--{$relBoundary}\r\n"
. "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\"\r\n\r\n"
. $altBody;
// Remaining parts: inline images addressed by CID
foreach ($inline as $img) {
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
$mime = $img['mime'] ?? 'application/octet-stream';
$path = $img['path'] ?? '';
$data = ($path && is_file($path)) ? (string)file_get_contents($path) : '';
if ($data === '') continue; // silently skip missing images
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
$name = basename($path);
$relParts[] = "--{$relBoundary}\r\n"
. "Content-Type: {$mime}; name=\"" . $this->encodeHeader($name) . "\"\r\n"
. "Content-Transfer-Encoding: base64\r\n"
. "Content-ID: <{$cid}>\r\n"
. "Content-Disposition: inline; filename=\"" . $this->encodeHeader($name) . "\"\r\n\r\n"
. $b64;
}
$relBody = implode("\r\n", $relParts) . "\r\n--{$relBoundary}--";
$topHeaders[] = "Content-Type: multipart/related; type=\"multipart/alternative\"; boundary=\"{$relBoundary}\"";
$message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $relBody . "\r\n.";
$this->send_line($message);
$this->expect(250);
return $messageId;
}
private function quit(): void {
$this->send_line('QUIT');
// Don't hard-fail if the server misbehaves after our last command.
@fgets($this->sock);
$this->closeSocket();
}
private function closeSocket(): void {
if (is_resource($this->sock)) { @fclose($this->sock); }
$this->sock = null;
}
/** Send a line (appends CRLF). */
private function send_line(string $line): void {
$this->log[] = '> ' . $this->safeLogLine($line);
fwrite($this->sock, $line . "\r\n");
}
/** Read lines until a final reply (no '-' separator), assert the
* expected 3-digit code. With $multi=true accepts same code across
* continuation lines (e.g. EHLO's capability list). */
private function expect(int $code, bool $multi = false): string {
$last = '';
while (!feof($this->sock)) {
$line = fgets($this->sock, 1024);
if ($line === false) throw new \RuntimeException('read failed');
$this->log[] = '< ' . rtrim($line);
$last .= $line;
if (strlen($line) < 4) throw new \RuntimeException('short reply');
$actual = (int)substr($line, 0, 3);
if ($actual !== $code) throw new \RuntimeException("expected $code, got " . rtrim($line));
// '-' means continuation, ' ' or end means final
if ($line[3] !== '-') break;
if (!$multi) break;
}
return $last;
}
/** Encode a header value if it contains non-ASCII. Keeps the subject
* readable for clients that support RFC 2047 encoded-word. */
private function encodeHeader(string $v): string {
if (preg_match('/[\x80-\xff]/', $v)) {
return '=?UTF-8?B?' . base64_encode($v) . '?=';
}
return $v;
}
/** RFC 5321 §4.5.2 dot-stuffing: a leading dot on a body line would
* end the DATA transmission early. Double any such dot. */
private function dotStuff(string $body): string {
// Normalise line endings to CRLF
$body = preg_replace("/\r\n|\r|\n/", "\r\n", $body);
return preg_replace('/^\./m', '..', $body);
}
/** Redact the LOGIN base64 so a saved log doesn't leak credentials. */
private function safeLogLine(string $line): string {
if (preg_match('/^[A-Za-z0-9+\/=]+$/', $line) && strlen($line) > 8) return '<base64 redacted>';
return $line;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
// API-key auth for hyprcrd-facing routes. One shared secret, sent via
// X-Support-Key. Compared with hash_equals to avoid timing leaks.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
function supportRelayApiAuth(PDO $db): bool {
$key = $_SERVER['HTTP_X_SUPPORT_KEY'] ?? '';
if ($key === '') return false;
$stored = supportRelayGet($db, 'api_key', '') ?: '';
if ($stored === '') return false;
return hash_equals($stored, $key);
}
+242
View File
@@ -0,0 +1,242 @@
<?php
// DB, master-key, schema, shared helpers. All include files require
// SUPPORT_RELAY_BOOTSTRAPPED to be defined by index.php.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
error_reporting(0);
ini_set('display_errors', '0');
$SUPPORT_ROOT = dirname(__DIR__);
$dbPath = $SUPPORT_ROOT . '/data/support.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
// Master key for encrypting SMTP creds + admin password hash context
$masterKeyPath = $SUPPORT_ROOT . '/data/master.key';
if (!file_exists($masterKeyPath)) {
file_put_contents($masterKeyPath, bin2hex(random_bytes(32)));
@chmod($masterKeyPath, 0600);
}
$MASTER_KEY = trim(file_get_contents($masterKeyPath));
$db = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('PRAGMA synchronous = NORMAL');
$db->exec('PRAGMA foreign_keys = ON');
// ── Schema ─────────────────────────────────────────────────────────
$db->exec('CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)');
// Tickets are the source of truth here. hyprcrd mirrors them as cards.
// status: open (user submitted) | answered (we sent a reply) | closed
// message_id: Message-ID of the first outbound reply, used for email
// threading so user replies land back under the same thread.
$db->exec('CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT "open",
message_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_reply_at TEXT
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_updated ON tickets(updated_at DESC)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status, updated_at DESC)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_email ON tickets(email)');
// Per-ticket reply log. direction: in (from user via form) | out (from
// hyprcrd → SMTP). sent_at is populated on successful SMTP delivery.
$db->exec('CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
direction TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_ticket ON replies(ticket_id, created_at)');
// Rate limits for the public form (IP-keyed, tumbling windows)
$db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
ip TEXT NOT NULL,
endpoint TEXT NOT NULL,
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
// Lightweight cleanup on every request (runs only old rows)
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-1 hour')");
// ── Encryption helpers (SMTP password is stored encrypted) ─────────
function supportRelayEncrypt(string $plain, string $masterKeyHex): string {
$key = hex2bin($masterKeyHex);
$nonce = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
return base64_encode($nonce . $cipher . $tag);
}
function supportRelayDecrypt(string $encoded, string $masterKeyHex): string {
$raw = base64_decode($encoded, true);
if (!$raw || strlen($raw) < 12 + 16 + 1) return '';
$key = hex2bin($masterKeyHex);
$nonce = substr($raw, 0, 12);
$tag = substr($raw, -16);
$cipher = substr($raw, 12, -16);
return openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag) ?: '';
}
// ── Settings helpers ───────────────────────────────────────────────
function supportRelayGet(PDO $db, string $key, ?string $default = null): ?string {
$stmt = $db->prepare('SELECT value FROM settings WHERE key = :k');
$stmt->execute(['k' => $key]);
$v = $stmt->fetchColumn();
return $v === false ? $default : $v;
}
function supportRelaySet(PDO $db, string $key, ?string $value): void {
if ($value === null) {
$db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => $key]);
} else {
$db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)')
->execute(['k' => $key, 'v' => $value]);
}
}
function supportRelaySetupDone(PDO $db): bool {
return supportRelayGet($db, 'setup_done') === '1';
}
function supportRelayIssueId(PDO $db, int $ticketId): string {
$prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA') ?: 'OUMTA';
return $prefix . '-' . $ticketId;
}
function supportRelayParseIssueId(PDO $db, string $s): ?int {
$prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA') ?: 'OUMTA';
$s = trim($s);
if (preg_match('/^' . preg_quote($prefix, '/') . '-(\d+)$/i', $s, $m)) return (int)$m[1];
if (preg_match('/^\d+$/', $s)) return (int)$s;
return null;
}
function supportRelaySmtpConfig(PDO $db, string $masterKeyHex): array {
$encPw = supportRelayGet($db, 'smtp_password', '') ?: '';
return [
'host' => supportRelayGet($db, 'smtp_host', '') ?: '',
'port' => (int)(supportRelayGet($db, 'smtp_port', '587') ?: 587),
'user' => supportRelayGet($db, 'smtp_user', '') ?: '',
'password' => $encPw ? supportRelayDecrypt($encPw, $masterKeyHex) : '',
'from' => supportRelayGet($db, 'smtp_from', '') ?: '',
'from_name' => supportRelayGet($db, 'smtp_from_name', 'Support') ?: 'Support',
'encryption' => supportRelayGet($db, 'smtp_encryption', 'starttls') ?: 'starttls',
];
}
// ── Rate limiting ──────────────────────────────────────────────────
function supportRelayRateLimited(PDO $db, string $endpoint, int $max = 5, int $windowMinutes = 10): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip === '') return false;
$count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')");
$count->execute(['ip' => $ip, 'ep' => $endpoint]);
if ((int)$count->fetchColumn() >= $max) return true;
$db->prepare('INSERT INTO rate_limits (ip, endpoint) VALUES (:ip, :ep)')->execute(['ip' => $ip, 'ep' => $endpoint]);
return false;
}
// ── Response helper ────────────────────────────────────────────────
function supportRelayJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
// ── Mail footer (configurable template) ───────────────────────────
/** Default footer if the admin hasn't set one in the config. */
const SUPPORT_RELAY_FOOTER_DEFAULT = "---\n{brand} · Ticket {issue_id}\nReply via {public_url} (issue id: {issue_id})";
/** Substitute the supported placeholders in the footer template.
* Whole lines containing a placeholder that resolves to empty are
* dropped, so "Reply via {public_url}" vanishes cleanly when no
* public URL is configured. */
function supportRelayRenderFooter(PDO $db, string $issueId): string {
$tpl = supportRelayGet($db, 'mail_footer', '') ?: SUPPORT_RELAY_FOOTER_DEFAULT;
$vars = [
'brand' => supportRelayGet($db, 'brand', 'Support') ?: 'Support',
'issue_id' => $issueId,
'public_url' => rtrim((string)(supportRelayGet($db, 'public_url', '') ?: ''), '/'),
];
$lines = [];
foreach (explode("\n", $tpl) as $line) {
// Identify the placeholders referenced on this line; drop the line
// if any of them is empty. Keeps the footer tidy when public_url
// or similar optional fields haven't been filled in.
if (preg_match_all('/\{([a-z_]+)\}/', $line, $m)) {
$skip = false;
foreach ($m[1] as $k) { if (!isset($vars[$k]) || $vars[$k] === '') { $skip = true; break; } }
if ($skip) continue;
}
$lines[] = strtr($line, array_combine(
array_map(fn($k) => '{' . $k . '}', array_keys($vars)),
array_values($vars)
));
}
return implode("\n", $lines);
}
// ── Mail rendering (plain + HTML + optional header logo) ───────────
/** Locate a header logo on disk. PNG wins over SVG (broadest mail-client
* support); SVG falls back for setups where only an SVG is shipped.
* Returns ['path', 'mime'] or null. */
function supportRelayLogo(): ?array {
$root = dirname(__DIR__);
$candidates = [
['path' => $root . '/logo2026col.png', 'mime' => 'image/png'],
['path' => $root . '/logo2026col.svg', 'mime' => 'image/svg+xml'],
];
foreach ($candidates as $c) {
if (is_file($c['path'])) return $c;
}
return null;
}
/** Wrap a plain-text body in a tiny HTML shell with the brand logo
* floated at the top. Escapes text, preserves line breaks. */
function supportRelayHtmlWrap(string $plain, string $brand, bool $hasLogo): string {
$safe = htmlspecialchars($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$safe = nl2br($safe);
$logo = $hasLogo
? '<img src="cid:logo" alt="' . htmlspecialchars($brand, ENT_QUOTES) . '" style="max-height:48px;display:block;margin-bottom:18px;">'
: '';
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
. '<body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#1a2438;line-height:1.55;padding:20px;max-width:620px;margin:0 auto;">'
. $logo
. '<div style="font-size:14px;">' . $safe . '</div>'
. '</body></html>';
}
/** Build the $options array that SmtpClient::send expects for a
* logo-branded mail. Returns an empty array when there's no logo on
* disk — the message then goes out as plain-text only (same as
* before this feature existed). */
function supportRelayMailOptions(string $plain, string $brand): array {
$logo = supportRelayLogo();
$html = supportRelayHtmlWrap($plain, $brand, $logo !== null);
$opts = ['html' => $html];
if ($logo) $opts['inlineImages'] = [['cid' => 'logo', 'path' => $logo['path'], 'mime' => $logo['mime']]];
return $opts;
}
+139
View File
@@ -0,0 +1,139 @@
<?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]);
}
+100
View File
@@ -0,0 +1,100 @@
<?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(),
]);
}
+17
View File
@@ -0,0 +1,17 @@
<?php
// Non-API routes for the human-facing pages: the submit form at `/`,
// the "check my ticket" status page at `/ticket/XXX`.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
if ($route === '/' || $route === '/index.php' || $route === '') {
require __DIR__ . '/../../ui/form.php';
exit;
}
if (preg_match('#^/ticket/([A-Za-z0-9-]+)$#', $route, $m)) {
// Render the public status view. Actual data comes via the JS call
// to /api/ticket/{id}?email=… once the user types their email.
require __DIR__ . '/../../ui/status.php';
exit;
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// /api/setup — one-shot configuration on first install. After the admin
// completes this form the API key becomes required for every /api/*
// route and the setup endpoint returns 403. Designed to be impossible
// to re-trigger without clearing `settings.setup_done` in the DB.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
if ($route === '/api/setup' && $method === 'POST') {
if (!$isSetup) supportRelayJson(['error' => 'already configured'], 403);
if (supportRelayRateLimited($db, 'setup', 10, 10)) supportRelayJson(['error' => 'too many attempts'], 429);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
// Required fields
$brand = trim((string)($input['brand'] ?? 'Oumta Support'));
$prefix = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)($input['issue_prefix'] ?? 'OUMTA')));
$publicUrl = rtrim((string)($input['public_url'] ?? ''), '/');
$footer = trim((string)($input['mail_footer'] ?? ''));
$smtpHost = trim((string)($input['smtp_host'] ?? ''));
$smtpPort = (int)($input['smtp_port'] ?? 587);
$smtpUser = trim((string)($input['smtp_user'] ?? ''));
$smtpPw = (string)($input['smtp_password'] ?? '');
$smtpFrom = trim((string)($input['smtp_from'] ?? ''));
$smtpFromN = trim((string)($input['smtp_from_name'] ?? $brand));
$smtpEnc = in_array($input['smtp_encryption'] ?? 'starttls', ['starttls', 'tls', 'none'], true)
? $input['smtp_encryption'] : 'starttls';
if ($smtpHost === '') supportRelayJson(['error' => 'SMTP host required'], 400);
if (!filter_var($smtpFrom, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'SMTP from must be a valid email'], 400);
if ($prefix === '' || strlen($prefix) > 12) supportRelayJson(['error' => 'issue prefix 1-12 chars, letters/digits only'], 400);
// Generate the API key now, show it back ONCE — admin copies it to
// hyprcrd. A second request after setup can't re-read or rotate it
// except through a re-install (clear setup_done). That's fine for a
// single-user admin workflow.
$apiKey = bin2hex(random_bytes(24));
supportRelaySet($db, 'brand', $brand);
supportRelaySet($db, 'issue_prefix', $prefix);
if ($publicUrl !== '') supportRelaySet($db, 'public_url', $publicUrl);
if ($footer !== '') supportRelaySet($db, 'mail_footer', $footer);
supportRelaySet($db, 'smtp_host', $smtpHost);
supportRelaySet($db, 'smtp_port', (string)$smtpPort);
supportRelaySet($db, 'smtp_user', $smtpUser);
if ($smtpPw !== '') supportRelaySet($db, 'smtp_password', supportRelayEncrypt($smtpPw, $MASTER_KEY));
supportRelaySet($db, 'smtp_from', $smtpFrom);
supportRelaySet($db, 'smtp_from_name', $smtpFromN);
supportRelaySet($db, 'smtp_encryption', $smtpEnc);
supportRelaySet($db, 'api_key', $apiKey);
supportRelaySet($db, 'setup_done', '1');
supportRelayJson(['ok' => true, 'api_key' => $apiKey]);
}