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

+86
View File
@@ -0,0 +1,86 @@
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$brand = supportRelayGet($db, 'brand', 'Support');
$prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA');
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($brand) ?></title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<main>
<h1><?= htmlspecialchars($brand) ?></h1>
<p class="lede">Describe your issue and we'll reply by email. If you already opened a ticket, enter the <span class="issue-id"><?= htmlspecialchars($prefix) ?>-…</span> id so we can keep the thread together.</p>
<div id="banner"></div>
<form id="support-form" novalidate>
<div class="field">
<label for="email">Your email</label>
<input id="email" name="email" type="email" required autocomplete="email">
</div>
<div class="field">
<label for="subject">Subject</label>
<input id="subject" name="subject" type="text" maxlength="200">
</div>
<div class="field">
<label for="body">Message</label>
<textarea id="body" name="body" required minlength="10" maxlength="10000"></textarea>
<span class="hint">Markdown is supported. The more specific the better.</span>
</div>
<div class="field">
<label for="issue_id">Reply to existing ticket (optional)</label>
<input id="issue_id" name="issue_id" type="text" placeholder="<?= htmlspecialchars($prefix) ?>-42" autocomplete="off">
<span class="hint">Leave empty for a new request.</span>
</div>
<div class="honey" aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
<button type="submit">Send</button>
</form>
</main>
<script>
const $ = (id) => document.getElementById(id);
const banner = $('banner');
function setBanner(text, kind = '') {
banner.className = 'banner ' + kind;
banner.textContent = text;
}
$('support-form').addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true; btn.textContent = 'Sending…';
const payload = {
email: $('email').value.trim(),
subject: $('subject').value.trim(),
body: $('body').value,
issue_id: $('issue_id').value.trim(),
website: $('website').value,
};
try {
const res = await fetch('api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Submission failed.');
const msg = data.appended
? `Added to your existing ticket ${data.issue_id}. We'll get back to you by email.`
: `Received. Your issue id is ${data.issue_id} — check your inbox for the confirmation.`;
setBanner(msg, 'ok');
e.target.reset();
} catch (err) {
setBanner(err.message || 'Something went wrong.', 'err');
} finally {
btn.disabled = false; btn.textContent = 'Send';
}
});
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } ?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Not found</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<main>
<h1>Not found</h1>
<p class="lede">This page doesn't exist. Try <a href=".">the support form</a>.</p>
</main>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } ?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Support relay — setup</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<main>
<h1>Relay setup</h1>
<p class="lede">First-run configuration. Once you save, this page disappears and the relay starts accepting tickets.</p>
<div id="banner"></div>
<form id="setup-form" novalidate>
<h2>Branding</h2>
<div class="field">
<label for="brand">Display name</label>
<input id="brand" type="text" value="Oumta Support" required>
<span class="hint">Shown on the public form, in email subjects, and at the bottom of replies.</span>
</div>
<div class="field">
<label for="issue_prefix">Issue-id prefix</label>
<input id="issue_prefix" type="text" value="OUMTA" pattern="[A-Za-z0-9]{1,12}" required>
<span class="hint">Tickets will look like <span class="issue-id">OUMTA-42</span>.</span>
</div>
<div class="field">
<label for="public_url">Public form URL (optional)</label>
<input id="public_url" type="url" placeholder="https://support.oumta.de">
<span class="hint">Shown in outbound mail footers so users can reply via the form with their issue id.</span>
</div>
<div class="field">
<label for="mail_footer">Mail footer (optional)</label>
<textarea id="mail_footer" rows="4" placeholder="---&#10;{brand} · Ticket {issue_id}&#10;Reply via {public_url} (issue id: {issue_id})"></textarea>
<span class="hint">Appended to every outbound mail (confirmations + replies). Supports placeholders <code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>. Lines with an empty placeholder are dropped. Leave empty to use the default.</span>
</div>
<h2>SMTP</h2>
<div class="field">
<label for="smtp_host">Host</label>
<input id="smtp_host" type="text" placeholder="smtp.example.com" required>
</div>
<div class="field">
<label for="smtp_port">Port</label>
<input id="smtp_port" type="number" value="587">
</div>
<div class="field">
<label for="smtp_encryption">Encryption</label>
<select id="smtp_encryption">
<option value="starttls" selected>STARTTLS (587)</option>
<option value="tls">Implicit TLS (465)</option>
<option value="none">None (25, not recommended)</option>
</select>
</div>
<div class="field">
<label for="smtp_user">Username</label>
<input id="smtp_user" type="text" autocomplete="off">
</div>
<div class="field">
<label for="smtp_password">Password</label>
<input id="smtp_password" type="password" autocomplete="off">
</div>
<div class="field">
<label for="smtp_from">From address</label>
<input id="smtp_from" type="email" placeholder="support@oumta.de" required>
</div>
<div class="field">
<label for="smtp_from_name">From name</label>
<input id="smtp_from_name" type="text" placeholder="Oumta Support">
</div>
<button type="submit">Save & generate API key</button>
</form>
<div id="done" style="display:none;">
<h2>Relay is configured.</h2>
<p class="lede">Paste this API key into the hyprcrd <strong>support</strong> plugin settings, next to the relay URL. It's shown once — copy it now.</p>
<div class="setup-note" id="apikey"></div>
<p class="lede" style="margin-top:20px;">Once hyprcrd is connected, the public form is live at this URL.</p>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const banner = $('banner');
function setBanner(text, kind = 'err') {
banner.className = 'banner ' + kind;
banner.textContent = text;
}
$('setup-form').addEventListener('submit', async e => {
e.preventDefault();
const payload = {
brand: $('brand').value.trim(),
issue_prefix: $('issue_prefix').value.trim().toUpperCase(),
public_url: $('public_url').value.trim(),
mail_footer: $('mail_footer').value,
smtp_host: $('smtp_host').value.trim(),
smtp_port: parseInt($('smtp_port').value) || 587,
smtp_encryption: $('smtp_encryption').value,
smtp_user: $('smtp_user').value.trim(),
smtp_password: $('smtp_password').value,
smtp_from: $('smtp_from').value.trim(),
smtp_from_name: $('smtp_from_name').value.trim() || $('brand').value.trim(),
};
try {
const res = await fetch('api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Setup failed.');
$('setup-form').style.display = 'none';
$('apikey').textContent = data.api_key;
$('done').style.display = '';
banner.textContent = '';
} catch (err) {
setBanner(err.message);
}
});
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$brand = supportRelayGet($db, 'brand', 'Support');
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket status — <?= htmlspecialchars($brand) ?></title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<main>
<h1>Ticket status</h1>
<p class="lede">Enter the email you submitted with to see the latest updates on your ticket.</p>
<div id="banner"></div>
<form id="status-form" novalidate>
<div class="field">
<label for="email">Your email</label>
<input id="email" type="email" required autocomplete="email">
</div>
<button type="submit">View</button>
</form>
<div id="ticket" style="display:none;margin-top:24px;"></div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const banner = $('banner');
const issueId = location.pathname.replace(/.*\/ticket\//, '');
function esc(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
$('status-form').addEventListener('submit', async e => {
e.preventDefault();
const email = $('email').value.trim();
banner.className = ''; banner.textContent = '';
const res = await fetch('api/ticket/' + encodeURIComponent(issueId) + '?email=' + encodeURIComponent(email));
const data = await res.json();
if (!res.ok) {
banner.className = 'banner err';
banner.textContent = data.error || 'Not found.';
$('ticket').style.display = 'none';
return;
}
const replies = (data.replies || []).map(r => `
<div class="reply">
<div class="reply-time">${esc(r.created_at)}</div>
${esc(r.body).replace(/\n/g, '<br>')}
</div>
`).join('') || '<p class="lede">No replies yet. We\'ll email you as soon as there is news.</p>';
$('ticket').innerHTML = `
<div class="ticket-header">
<h2>${esc(data.subject || '(no subject)')}</h2>
<div class="ticket-meta"><span class="issue-id">${esc(data.issue_id)}</span> · status: <strong>${esc(data.status)}</strong> · opened ${esc(data.created_at)}</div>
</div>
${replies}
`;
$('ticket').style.display = '';
});
</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
/* support-relay public + setup styles. Minimalist, self-contained —
* no font loading, no external deps, passes through whatever the
* system UI stack looks like. Dark + light friendly. */
:root {
--bg: #0f1726;
--surface: #1a2438;
--border: #2a3650;
--text: #cbd5e1;
--bright: #f1f5f9;
--dim: #64748b;
--accent: #38bdf8;
--ok: #4ade80;
--danger: #fb7185;
--radius: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 15px;
line-height: 1.55;
}
main {
max-width: 560px;
margin: 0 auto;
padding: 48px 20px 64px;
}
h1 { color: var(--bright); font-size: 26px; margin-bottom: 6px; }
h2 { color: var(--bright); font-size: 18px; margin: 20px 0 8px; }
.lede { color: var(--dim); margin-bottom: 28px; font-size: 14px; }
form { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
input, textarea, select {
background: var(--surface);
border: 1px solid var(--border);
color: var(--bright);
padding: 10px 12px;
border-radius: var(--radius);
font: inherit;
font-size: 14px;
outline: none;
transition: border-color .15s;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 140px; font-family: inherit; }
.hint { font-size: 12px; color: var(--dim); }
button[type="submit"], .btn {
appearance: none;
background: var(--accent);
color: #001526;
border: none;
font-size: 14px;
font-weight: 600;
padding: 12px 20px;
border-radius: var(--radius);
cursor: pointer;
transition: filter .15s, transform .05s;
}
button[type="submit"]:hover, .btn:hover { filter: brightness(1.1); }
button[type="submit"]:active, .btn:active { transform: translateY(1px); }
.honey {
position: absolute;
left: -9999px;
width: 1px; height: 1px;
overflow: hidden;
opacity: 0;
}
.banner {
padding: 12px 16px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
margin-bottom: 18px;
font-size: 13px;
}
.banner.ok { border-color: var(--ok); color: var(--ok); }
.banner.err { border-color: var(--danger); color: var(--danger); }
.issue-id {
font-family: ui-monospace, 'JetBrains Mono', monospace;
background: rgba(56, 189, 248, 0.12);
padding: 2px 6px;
border-radius: 3px;
color: var(--accent);
}
a { color: var(--accent); }
/* Status / reply cards */
.ticket-header { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 16px; }
.ticket-header h2 { margin: 0 0 4px; font-size: 15px; }
.ticket-meta { color: var(--dim); font-size: 12px; }
.reply {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
white-space: pre-wrap;
}
.reply-time { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.setup-note {
background: rgba(74, 222, 128, 0.12);
border: 1px solid var(--ok);
color: var(--ok);
padding: 14px 16px;
border-radius: var(--radius);
font-family: ui-monospace, monospace;
font-size: 13px;
word-break: break-all;
}