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>
238 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|