+ Connect hyprcrd to your support relay. The relay hosts the public form and
+ sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply
+ from any card.
+
+
+
+
+
+
+
+
+
Leave empty to keep the currently stored key.
+
+
+
+
+
Where newly-pulled ticket cards will land.
+
+
+
+
+
Cards in this bucket become reusable reply fragments.
+
+
+
+
+
+
+
+
Relay config
+
+ Editable post-setup values on the connected relay. Mail footer supports
+ {brand}, {issue_id}, {public_url}
+ placeholders — lines containing an empty placeholder are dropped.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Populate bucket selects from state.buckets
+ const opts = (window.state?.buckets || []).map(b => ``).join('');
+ panelEl.querySelector('#support-set-inbox').innerHTML += opts;
+ panelEl.querySelector('#support-set-kb').innerHTML += opts;
+
+ api('api/support/settings').then(cur => {
+ panelEl.querySelector('#support-set-url').value = cur.relay_url || '';
+ panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup';
+ if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox;
+ if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb;
+ }).catch(() => {});
+
+ const statusEl = panelEl.querySelector('#support-set-status');
+ function setStatus(msg, kind = '') {
+ statusEl.textContent = msg;
+ statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
+ }
+
+ panelEl.querySelector('#support-set-save').addEventListener('click', async () => {
+ const payload = {
+ relay_url: panelEl.querySelector('#support-set-url').value.trim(),
+ bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0,
+ bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0,
+ };
+ const key = panelEl.querySelector('#support-set-key').value;
+ if (key) payload.relay_key = key;
+ try {
+ await api('api/support/settings', { method: 'POST', body: payload });
+ setStatus('Saved.', 'ok');
+ } catch (e) { setStatus(e.message, 'err'); }
+ });
+
+ panelEl.querySelector('#support-set-test').addEventListener('click', async () => {
+ setStatus('Testing…');
+ try {
+ const r = await api('api/support/settings/test', { method: 'POST' });
+ setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok');
+ } catch (e) { setStatus(e.message, 'err'); }
+ });
+
+ // Relay config (brand / public url / footer). Loaded after a tick so
+ // the local-settings render happens first — keeps this lazy, and
+ // silently hides if the relay isn't reachable.
+ const rsStatus = panelEl.querySelector('#support-rs-status');
+ function setRsStatus(msg, kind = '') {
+ rsStatus.textContent = msg;
+ rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
+ }
+ (async () => {
+ try {
+ const rs = await api('api/support/relay-settings');
+ panelEl.querySelector('#support-rs-brand').value = rs.brand || '';
+ panelEl.querySelector('#support-rs-public').value = rs.public_url || '';
+ panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || '';
+ const def = panelEl.querySelector('#support-rs-footer-default');
+ if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ ');
+ } catch (e) {
+ setRsStatus('(relay unreachable — configure above first)', 'err');
+ }
+ })();
+ panelEl.querySelector('#support-rs-save').addEventListener('click', async () => {
+ setRsStatus('Saving…');
+ try {
+ await api('api/support/relay-settings', { method: 'POST', body: {
+ brand: panelEl.querySelector('#support-rs-brand').value.trim(),
+ public_url: panelEl.querySelector('#support-rs-public').value.trim(),
+ mail_footer: panelEl.querySelector('#support-rs-footer').value,
+ }});
+ setRsStatus('Saved.', 'ok');
+ } catch (e) {
+ setRsStatus(e.message, 'err');
+ }
+ });
+}
+
+h?.settings?.registerTab?.({
+ id: 'support',
+ label: 'support',
+ icon: 'ph-lifebuoy',
+ onShow: renderSettingsPanel,
+});
+
+// Hook the sync tick so each user-triggered sync also pulls support.
+h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); });
+
+h?.registerPlugin?.({ name: 'support', version: '1.0.0' });
diff --git a/support-relay/.dockerignore b/support-relay/.dockerignore
new file mode 100644
index 0000000..bf30153
--- /dev/null
+++ b/support-relay/.dockerignore
@@ -0,0 +1,6 @@
+data/*.db
+data/*.db-*
+data/master.key
+.git
+.gitignore
+docker-compose.yml
diff --git a/support-relay/.htaccess b/support-relay/.htaccess
new file mode 100644
index 0000000..2cbd172
--- /dev/null
+++ b/support-relay/.htaccess
@@ -0,0 +1,12 @@
+RewriteEngine On
+
+# Keep the DB + master key out of the web path (defence-in-depth; the
+# Apache doc-root should already be the project root, not /data).
+RewriteRule ^data(/|$) - [F,L]
+
+# Everything else funnels through index.php so route dispatch is
+# centralised. Actual files (ui/style.css, etc.) continue to be
+# served directly.
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^ index.php [L]
diff --git a/support-relay/Dockerfile b/support-relay/Dockerfile
new file mode 100644
index 0000000..e2e5937
--- /dev/null
+++ b/support-relay/Dockerfile
@@ -0,0 +1,25 @@
+FROM php:8.3-apache
+
+# Apache rewrite so .htaccess routing works
+RUN a2enmod rewrite
+
+# SQLite + PDO
+RUN apt-get update && apt-get install -y libsqlite3-dev \
+ && rm -rf /var/lib/apt/lists/* \
+ && docker-php-ext-install pdo_sqlite
+
+# Let per-directory .htaccess override (needed for our RewriteRule)
+RUN sed -ri 's!AllowOverride None!AllowOverride All!g' /etc/apache2/apache2.conf
+
+# App files. data/ is also mounted as a volume in docker-compose so the
+# DB + master key persist across rebuilds — but we COPY it too so a
+# bare `docker run` still boots.
+COPY . /var/www/html/
+
+# Entrypoint makes sure data/ is writable by www-data inside the
+# container (mounts from the host can otherwise land as root:root).
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+EXPOSE 80
+CMD ["docker-entrypoint.sh"]
diff --git a/support-relay/README.md b/support-relay/README.md
new file mode 100644
index 0000000..96fa8d3
--- /dev/null
+++ b/support-relay/README.md
@@ -0,0 +1,197 @@
+# Support-Relay — Setup
+
+Nimmt anonyme Support-Anfragen per Web-Formular entgegen, legt sie in
+einer lokalen SQLite-DB ab, versendet Bestätigungs- und Antwort-Mails
+per SMTP. Hyprcrd zieht sich die Tickets und antwortet durch diesen
+Relay hindurch. **Single-user** — ein Admin, ein API-Key.
+
+## Lokal testen mit Docker (einfachster Weg)
+
+```sh
+cd support-relay
+docker compose up -d --build
+```
+
+Danach `http://localhost:8090/` im Browser öffnen → Setup-Wizard. Kein
+Host-Setup nötig. Die DB und der master-key landen im lokalen
+`./data/`-Ordner und überleben `docker compose down`.
+
+Zum Rücksetzen (neuer API-Key, leere DB):
+
+```sh
+rm -f data/support.db data/support.db-* data/master.key
+docker compose restart
+```
+
+Stoppen: `docker compose down`.
+
+---
+
+## Was du für Production brauchst
+
+- Eine Subdomain, z.B. `support.oumta.de`
+- PHP ≥ 8.1 mit `pdo_sqlite` und `openssl`
+- Apache mit `mod_rewrite` (oder nginx mit äquivalentem Rewrite)
+- Einen SMTP-Zugang (Mailserver, Mailtrap, Office365, was auch immer)
+
+---
+
+## 1. Upload
+
+Den kompletten Ordner `support-relay/` als Document-Root der Subdomain
+hochladen. Die Struktur sollte am Host so aussehen:
+
+```
+support.oumta.de/
+├── index.php
+├── .htaccess
+├── inc/
+├── ui/
+└── data/ ← muss vom Webserver schreibbar sein!
+```
+
+**`data/` schreibbar machen** (SSH):
+
+```sh
+chmod 755 data
+```
+
+Der Webserver legt dort `support.db` und `master.key` an. `master.key`
+kriegt automatisch `chmod 600`.
+
+---
+
+## 2. Erster Aufruf → Setup-Wizard
+
+`https://support.oumta.de/` im Browser öffnen. Wenn `data/` schreibbar
+ist, erscheint automatisch der Setup-Wizard. Ausfüllen:
+
+| Feld | Was eintragen |
+|---|---|
+| **Display name** | `Oumta Support` (wird Absendername, Subject-Präfix, Footer) |
+| **Issue-prefix** | `OUMTA` (Tickets heißen dann `OUMTA-1`, `OUMTA-2`, …) |
+| **Host** | z.B. `smtp.office365.com` / `smtp.mailtrap.io` / eigener Mailserver |
+| **Port** | `587` (STARTTLS — Standard) |
+| **Encryption** | `STARTTLS (587)` lassen, außer dein Server will was anderes |
+| **Username** | SMTP-User |
+| **Password** | SMTP-Passwort (wird verschlüsselt abgelegt) |
+| **From address** | z.B. `support@oumta.de` |
+| **From name** | `Oumta Support` |
+
+**Speichern** drücken. Die nächste Seite zeigt einen langen **API-Key**.
+Kopieren und sicher aufheben — wird **exakt einmal** angezeigt.
+
+Nach dem Setup ist diese Seite nicht mehr erreichbar. Stattdessen zeigt
+`/` jetzt das öffentliche Support-Formular.
+
+---
+
+## 3. Hyprcrd verbinden
+
+In hyprcrd:
+
+1. Zwei Buckets anlegen (falls noch nicht vorhanden):
+ - **Support Inbox** — hier landen eingehende Tickets als Karten
+ - **Support KB** — hier liegen deine wiederverwendbaren Antwort-Bausteine
+2. Settings (Zahnrad) öffnen → Tab **support**
+3. Eintragen:
+ - **Relay URL**: `https://support.oumta.de`
+ - **API key**: der gerade kopierte Key
+ - **Inbox bucket**: `Support Inbox`
+ - **KB bucket**: `Support KB`
+4. **Save** → **Test connection** → muss grün zurückkommen
+ (`OK — relay says "Oumta Support"`)
+
+---
+
+## 4. Smoke-Test
+
+**a) Ticket einreichen**
+
+In einem Inkognito-Tab `https://support.oumta.de/` öffnen, eine
+Testanfrage absenden. Erwartung:
+
+- Grüner Banner: `Received. Your issue id is OUMTA-1.`
+- Bestätigungsmail im Posteingang
+
+**b) Ticket in hyprcrd**
+
+Oben rechts den **Support**-Button im Header klicken → Overlay auf.
+Auf den Reload-Knopf (↻) klicken, dann erscheint das Ticket in der
+Liste. Im "Support Inbox"-Bucket liegt eine Karte mit derselben Info.
+
+**c) Antworten**
+
+Ticket anklicken → ins Textfeld tippen → **Send reply**. Erwartung:
+
+- Status wechselt auf `answered`
+- Mail landet im Posteingang des Absenders
+
+**d) Fragment speichern**
+
+Nach dem Tippen **Save as fragment** → Titel vergeben → die Antwort
+taucht im KB-Bucket als Karte auf und wird ab sofort im Suchfeld
+über dem Composer gefunden.
+
+---
+
+## 5. Normaler Betrieb
+
+- **Neue Tickets sehen**: Support-Overlay in hyprcrd. Pollt automatisch
+ bei jedem Sync-Tick; manueller Pull per ↻-Knopf.
+- **Antworten**: aus dem Composer heraus, SMTP läuft über den Relay.
+- **Status setzen**: `open` → `answered` (automatisch bei Reply) →
+ `closed` (manuell per Button).
+- **User antwortet auf eine Mail**: er muss seine **Issue-ID**
+ (`OUMTA-n`) erneut ins Formular eintragen. Dann wird die Nachricht
+ an das bestehende Ticket gehängt (nur wenn die Email matcht, sonst
+ wird daraus ein neues Ticket).
+
+---
+
+## Troubleshooting
+
+**Setup-Wizard kommt nicht**
+`data/` ist nicht schreibbar. → `chmod 755 data/` (oder 775) und neu
+laden.
+
+**"Test connection" schlägt fehl**
+Relay-URL falsch (Trailing-Slash ist OK, aber HTTPS muss stimmen) oder
+API-Key nicht exakt übernommen.
+
+**Confirmation-Mail kommt nicht**
+SMTP-Creds falsch oder Port blockiert. Direkt vom Host testen:
+
+```sh
+php -r '
+ require "inc/bootstrap.php"; require "inc/SmtpClient.php";
+ $c = supportRelaySmtpConfig(Database::connect(), $MASTER_KEY);
+ $r = (new SmtpClient($c))->send("du@example.com", $c["from_name"], $c["from"],
+ "ping", ["Subject" => "SMTP ping"]);
+ print_r($r);
+'
+```
+
+Der `log`-Array zeigt die SMTP-Konversation.
+
+**Alles kaputt, von vorn anfangen**
+`data/support.db` und `data/master.key` löschen. Nächster Aufruf
+startet wieder im Setup-Wizard — der API-Key ändert sich dann,
+musst du in hyprcrd neu eintragen.
+
+---
+
+## Dateien
+
+| Pfad | Zweck |
+|---|---|
+| `index.php` | Entry-Point + Routing |
+| `inc/bootstrap.php` | DB-Schema, Settings, Helpers |
+| `inc/SmtpClient.php` | Minimaler SMTP-Client |
+| `inc/routes/*.php` | Public / Setup / Hyprcrd-API |
+| `ui/*.php` | Form, Setup-Wizard, Status-Seite |
+| `ui/style.css` | Styling |
+| `data/support.db` | SQLite DB (tickets, replies, settings) |
+| `data/master.key` | Verschlüsselt SMTP-Passwort (chmod 600) |
+
+Gute Nacht 🌙
diff --git a/support-relay/data/.gitkeep b/support-relay/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/support-relay/data/.htaccess b/support-relay/data/.htaccess
new file mode 100644
index 0000000..adf32a3
--- /dev/null
+++ b/support-relay/data/.htaccess
@@ -0,0 +1,2 @@
+# Block all direct access to the data directory (SQLite, master key).
+Require all denied
diff --git a/support-relay/docker-compose.yml b/support-relay/docker-compose.yml
new file mode 100644
index 0000000..e0448b4
--- /dev/null
+++ b/support-relay/docker-compose.yml
@@ -0,0 +1,17 @@
+# Local-dev runner for the support-relay. One command:
+# docker compose up -d --build
+# then open http://localhost:8090/ to land on the setup wizard.
+#
+# `./data` is bind-mounted so the SQLite DB and master.key survive
+# `docker compose down` + container rebuilds. To start fresh: delete
+# everything in ./data/ (keep the folder itself).
+
+services:
+ support-relay:
+ build: .
+ container_name: support-relay
+ ports:
+ - "8090:80"
+ volumes:
+ - ./data:/var/www/html/data
+ restart: unless-stopped
diff --git a/support-relay/docker-entrypoint.sh b/support-relay/docker-entrypoint.sh
new file mode 100755
index 0000000..bfebb54
--- /dev/null
+++ b/support-relay/docker-entrypoint.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -e
+
+# Make sure the bind-mounted data dir is writable by the Apache user
+# inside the container. Bind mounts from the host land as root:root by
+# default which would prevent the relay from creating support.db +
+# master.key on first boot.
+mkdir -p /var/www/html/data
+chown -R www-data:www-data /var/www/html/data
+chmod 755 /var/www/html/data
+
+exec apache2-foreground
diff --git a/support-relay/favicon.svg b/support-relay/favicon.svg
new file mode 100644
index 0000000..634ea71
--- /dev/null
+++ b/support-relay/favicon.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/support-relay/inc/SmtpClient.php b/support-relay/inc/SmtpClient.php
new file mode 100644
index 0000000..ea594e0
--- /dev/null
+++ b/support-relay/inc/SmtpClient.php
@@ -0,0 +1,237 @@
+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
+ * 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 .
+ $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 '';
+ return $line;
+ }
+}
diff --git a/support-relay/inc/auth.php b/support-relay/inc/auth.php
new file mode 100644
index 0000000..3340d79
--- /dev/null
+++ b/support-relay/inc/auth.php
@@ -0,0 +1,13 @@
+ 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
+ ? ''
+ : '';
+ return ''
+ . ''
+ . $logo
+ . '
' . $safe . '
'
+ . '';
+}
+
+/** 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;
+}
diff --git a/support-relay/inc/routes/api.php b/support-relay/inc/routes/api.php
new file mode 100644
index 0000000..a05f15a
--- /dev/null
+++ b/support-relay/inc/routes/api.php
@@ -0,0 +1,139 @@
+ '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]);
+}
diff --git a/support-relay/inc/routes/public.php b/support-relay/inc/routes/public.php
new file mode 100644
index 0000000..ddfd56d
--- /dev/null
+++ b/support-relay/inc/routes/public.php
@@ -0,0 +1,100 @@
+ '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(),
+ ]);
+}
diff --git a/support-relay/inc/routes/public_html.php b/support-relay/inc/routes/public_html.php
new file mode 100644
index 0000000..f5c8511
--- /dev/null
+++ b/support-relay/inc/routes/public_html.php
@@ -0,0 +1,17 @@
+ '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]);
+}
diff --git a/support-relay/index.php b/support-relay/index.php
new file mode 100644
index 0000000..15435b5
--- /dev/null
+++ b/support-relay/index.php
@@ -0,0 +1,61 @@
+ 524288) { http_response_code(413); echo json_encode(['error' => 'payload too large']); exit; }
+
+ require __DIR__ . '/inc/routes/setup.php';
+ require __DIR__ . '/inc/routes/public.php';
+ require __DIR__ . '/inc/routes/api.php';
+
+ http_response_code(404);
+ echo json_encode(['error' => 'not found']);
+ exit;
+}
+
+// Setup UI (only while not yet configured)
+if ($isSetup) {
+ require __DIR__ . '/ui/setup.php';
+ exit;
+}
+
+// Public form + ticket status
+require __DIR__ . '/inc/routes/public_html.php';
+
+// Fallback: 404
+http_response_code(404);
+require __DIR__ . '/ui/notfound.php';
diff --git a/support-relay/logo2026col.svg b/support-relay/logo2026col.svg
new file mode 100644
index 0000000..7d2f6ae
--- /dev/null
+++ b/support-relay/logo2026col.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/support-relay/ui/form.php b/support-relay/ui/form.php
new file mode 100644
index 0000000..cd639b3
--- /dev/null
+++ b/support-relay/ui/form.php
@@ -0,0 +1,86 @@
+
+
+
+
+
+= htmlspecialchars($brand) ?>
+
+
+
+
+
+
= htmlspecialchars($brand) ?>
+
Describe your issue and we'll reply by email. If you already opened a ticket, enter the = htmlspecialchars($prefix) ?>-… id so we can keep the thread together.