Files
thandClaude Opus 4.7 7bce6a3a36 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>
2026-04-19 10:41:19 +02:00

238 lines
10 KiB
PHP

<?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;
}
}