diff --git a/server/bug-report-routes.ts b/server/bug-report-routes.ts
new file mode 100644
index 00000000..15c4bf43
--- /dev/null
+++ b/server/bug-report-routes.ts
@@ -0,0 +1,111 @@
+/**
+ * Bug Report API — accepts user-submitted bug reports and emails them.
+ */
+
+import { Hono } from "hono";
+import { getSmtpTransport } from "./notification-service";
+
+export const bugReportRouter = new Hono();
+
+const MAX_SCREENSHOT_BYTES = 3 * 1024 * 1024; // 3 MB
+
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+bugReportRouter.post("/", async (c) => {
+ let body: any;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON" }, 400);
+ }
+
+ const { userAgent, url, errors, comments, screenshot } = body as {
+ userAgent?: string;
+ url?: string;
+ errors?: string[];
+ comments?: string;
+ screenshot?: string; // data URL
+ };
+
+ if (!comments?.trim() && (!errors || errors.length === 0)) {
+ return c.json({ error: "Please provide comments or error details" }, 400);
+ }
+
+ // Screenshot handling
+ let attachments: any[] = [];
+ let screenshotCid = "";
+ if (screenshot && typeof screenshot === "string" && screenshot.startsWith("data:image/")) {
+ // Validate size (base64 is ~4/3 of raw, so check decoded)
+ const base64Part = screenshot.split(",")[1];
+ if (!base64Part) return c.json({ error: "Invalid screenshot data URL" }, 400);
+ const rawBytes = Buffer.from(base64Part, "base64");
+ if (rawBytes.length > MAX_SCREENSHOT_BYTES) {
+ return c.json({ error: "Screenshot exceeds 3 MB limit" }, 413);
+ }
+
+ // Determine extension from mime
+ const mimeMatch = screenshot.match(/^data:image\/(png|jpeg|jpg|gif|webp);/);
+ const ext = mimeMatch ? mimeMatch[1].replace("jpeg", "jpg") : "png";
+ const filename = `bugreport-${Date.now()}.${ext}`;
+
+ // Save to generated files
+ const savePath = `/data/files/generated/${filename}`;
+ try {
+ await Bun.write(savePath, rawBytes);
+ } catch {
+ // Non-fatal — still send email without saved file
+ }
+
+ screenshotCid = `screenshot-${Date.now()}`;
+ attachments = [{
+ filename,
+ content: rawBytes,
+ cid: screenshotCid,
+ contentType: `image/${ext === "jpg" ? "jpeg" : ext}`,
+ }];
+ }
+
+ // Build HTML email
+ const errorList = (errors && errors.length > 0)
+ ? errors.map(e => `
${escapeHtml(e)}`).join("")
+ : "";
+
+ const html = `
+
+
+
Bug Report
+
+ | Page URL | ${escapeHtml(url || "unknown")} |
+ | Device | ${escapeHtml(userAgent || "unknown")} |
+
+ ${errorList ? `
` : ""}
+ ${comments?.trim() ? `
Comments:${escapeHtml(comments)}
` : ""}
+ ${screenshotCid ? `
Screenshot:
` : ""}
+
+
Sent from rSpace Bug Reporter
+
`;
+
+ try {
+ const transport = await getSmtpTransport();
+ if (!transport) {
+ console.error("[bug-report] No SMTP transport available");
+ return c.json({ error: "Email service unavailable" }, 503);
+ }
+
+ await transport.sendMail({
+ from: "rSpace Bugs ",
+ to: "jeff@jeffemmett.com",
+ subject: `[Bug] ${url || "rSpace"} — ${(comments || "").slice(0, 60) || "Error report"}`,
+ html,
+ attachments,
+ });
+
+ console.log(`[bug-report] Report sent for ${url}`);
+ return c.json({ ok: true });
+ } catch (err: any) {
+ console.error("[bug-report] Failed to send:", err.message);
+ return c.json({ error: "Failed to send report" }, 500);
+ }
+});
diff --git a/server/index.ts b/server/index.ts
index c57f031b..20b2aa56 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -107,6 +107,7 @@ import { registerUserConnection, unregisterUserConnection, notify } from "./noti
import { SystemClock } from "./clock-service";
import type { ClockPayload } from "./clock-service";
import { miRoutes } from "./mi-routes";
+import { bugReportRouter } from "./bug-report-routes";
// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
process.on('uncaughtException', (err) => {
@@ -524,6 +525,9 @@ app.route("/api/mi", miRoutes);
app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes);
+// ── Bug Report API ──
+app.route("/api/bug-report", bugReportRouter);
+
// ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes);
diff --git a/server/notification-service.ts b/server/notification-service.ts
index ef748780..ce2b64ca 100644
--- a/server/notification-service.ts
+++ b/server/notification-service.ts
@@ -41,7 +41,7 @@ const SMTP_PASS = process.env.SMTP_PASS || "";
let _smtpTransport: any = null;
-async function getSmtpTransport() {
+export async function getSmtpTransport() {
if (_smtpTransport) return _smtpTransport;
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
if (!SMTP_PASS && !isInternal) return null;
diff --git a/server/shell.ts b/server/shell.ts
index c74d30bc..3c66c027 100644
--- a/server/shell.ts
+++ b/server/shell.ts
@@ -289,6 +289,7 @@ export function renderShell(opts: ShellOptions): string {
+
@@ -1566,6 +1567,93 @@ export function renderShell(opts: ShellOptions): string {
})();
}
+ ${renderBugReportWidget()}
+
${scripts}