cmux/web/app/api/feedback/route.ts
Lawrence Chen 29054dc709
Add sidebar help menu to footer (#958)
* Add sidebar help menu

* Fix help menu test wiring

* Fix help menu accessibility

* Use native popup for help menu

* Use icon button for sidebar help

* Add feedback composer and feedback API

* Allow preview builds without feedback env

* Tighten feedback upload limits

* Adjust sidebar footer padding

* Tighten sidebar footer spacing

* Add link affordances to help menu

* Polish sidebar feedback composer

* Move feedback icon to trailing edge

* Normalize help menu trailing icon sizes

* Enlarge help menu trailing icons

* Reduce help menu link icon size

* Shrink help menu link arrow

* Reduce help menu link arrow again

* Fix feedback message editor focus

* Add send feedback keyboard shortcut

* Polish feedback launch and delivery
2026-03-05 21:00:42 -08:00

340 lines
9.3 KiB
TypeScript

import { checkRateLimit } from "@vercel/firewall";
import { NextResponse } from "next/server";
import { Resend } from "resend";
import { z } from "zod";
import { env } from "@/app/env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const feedbackRecipient = "founders@manaflow.com";
const maxAttachmentCount = 10;
const maxAttachmentBytes = 4 * 1024 * 1024;
// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit.
const maxTotalAttachmentBytes = 4 * 1024 * 1024;
const allowedImageTypes = new Set([
"image/gif",
"image/heic",
"image/heif",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
]);
const feedbackSchema = z.object({
email: z.string().trim().email().max(320),
message: z.string().trim().min(1).max(4000),
appVersion: z.string().trim().max(120).optional().default(""),
appBuild: z.string().trim().max(120).optional().default(""),
appCommit: z.string().trim().max(120).optional().default(""),
bundleIdentifier: z.string().trim().max(200).optional().default(""),
osVersion: z.string().trim().max(200).optional().default(""),
locale: z.string().trim().max(120).optional().default(""),
});
type PreparedAttachment = {
content: Buffer;
contentType: string;
filename: string;
size: number;
};
export async function POST(request: Request) {
const feedbackConfig = resolveFeedbackConfig();
if (!feedbackConfig) {
return jsonError("Feedback endpoint is not configured", 503);
}
if (process.env.VERCEL === "1") {
const { error, rateLimited } = await checkRateLimit(
feedbackConfig.rateLimitId,
{ request },
);
if (rateLimited || error === "blocked") {
return jsonError("Rate limit exceeded", 429);
}
if (error === "not-found") {
console.error(
"feedback.route.rate_limit_not_found",
feedbackConfig.rateLimitId,
);
} else if (error) {
console.error("feedback.route.rate_limit_error", error);
}
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return jsonError("Invalid multipart payload", 400);
}
const parsed = feedbackSchema.safeParse({
email: getString(formData, "email"),
message: getString(formData, "message"),
appVersion: getString(formData, "appVersion"),
appBuild: getString(formData, "appBuild"),
appCommit: getString(formData, "appCommit"),
bundleIdentifier: getString(formData, "bundleIdentifier"),
osVersion: getString(formData, "osVersion"),
locale: getString(formData, "locale"),
});
if (!parsed.success) {
return jsonError("Invalid feedback payload", 400);
}
const attachmentsResult = await prepareAttachments(
formData.getAll("attachments"),
);
if ("errorResponse" in attachmentsResult) {
return attachmentsResult.errorResponse;
}
const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } =
parsed.data;
const subject = buildSubject(email, message, appVersion);
const attachments = attachmentsResult.attachments;
const resend = new Resend(feedbackConfig.resendApiKey);
const { error } = await resend.emails.send({
from: `cmux feedback <${feedbackConfig.fromEmail}>`,
to: [feedbackRecipient],
replyTo: email,
subject,
text: buildTextBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
html: buildHtmlBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
attachments: attachments.map((attachment) => ({
content: attachment.content,
contentType: attachment.contentType,
filename: attachment.filename,
})),
});
if (error) {
console.error("feedback.route.resend_failed", error);
return jsonError("Failed to send feedback", 502);
}
return NextResponse.json(
{ ok: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
function resolveFeedbackConfig() {
const resendApiKey = env.RESEND_API_KEY;
const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL;
const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID;
if (!resendApiKey || !fromEmail || !rateLimitId) {
return null;
}
return {
resendApiKey,
fromEmail,
rateLimitId,
};
}
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
async function prepareAttachments(values: FormDataEntryValue[]) {
const files = values.filter(
(value): value is File => value instanceof File && value.name.length > 0,
);
if (files.length > maxAttachmentCount) {
return {
errorResponse: jsonError("Too many images attached", 400),
};
}
let totalSize = 0;
const attachments: PreparedAttachment[] = [];
for (const file of files) {
if (!allowedImageTypes.has(file.type)) {
return {
errorResponse: jsonError("Unsupported image attachment type", 415),
};
}
if (file.size > maxAttachmentBytes) {
return {
errorResponse: jsonError("Image attachment is too large", 413),
};
}
totalSize += file.size;
if (totalSize > maxTotalAttachmentBytes) {
return {
errorResponse: jsonError("Total image attachment size is too large", 413),
};
}
attachments.push({
content: Buffer.from(await file.arrayBuffer()),
contentType: file.type,
filename: sanitizeFilename(file.name),
size: file.size,
});
}
return { attachments };
}
function buildSubject(email: string, message: string, appVersion: string) {
const firstNonEmptyLine =
message
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? "Feedback";
const summary =
firstNonEmptyLine.length > 72
? `${firstNonEmptyLine.slice(0, 69)}...`
: firstNonEmptyLine;
const versionSuffix = appVersion ? ` (v${appVersion})` : "";
return `cmux feedback from ${email}${versionSuffix}: ${summary}`;
}
function buildTextBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentLines =
input.attachments.length === 0
? "Attachments: none"
: [
"Attachments:",
...input.attachments.map(
(attachment) =>
`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`,
),
].join("\n");
return [
`From: ${input.email}`,
`App version: ${input.appVersion || "unknown"}`,
`App build: ${input.appBuild || "unknown"}`,
`App commit: ${input.appCommit || "unknown"}`,
`Bundle identifier: ${input.bundleIdentifier || "unknown"}`,
`macOS: ${input.osVersion || "unknown"}`,
`Locale: ${input.locale || "unknown"}`,
attachmentLines,
"",
"Message:",
input.message,
].join("\n");
}
function buildHtmlBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentMarkup =
input.attachments.length === 0
? "<p><strong>Attachments:</strong> none</p>"
: `<p><strong>Attachments:</strong></p><ul>${input.attachments
.map(
(attachment) =>
`<li>${escapeHtml(attachment.filename)} (${escapeHtml(
attachment.contentType,
)}, ${attachment.size} bytes)</li>`,
)
.join("")}</ul>`;
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;line-height:1.5">
<h1 style="font-size:18px;margin:0 0 16px">cmux feedback</h1>
<p><strong>From:</strong> ${escapeHtml(input.email)}</p>
<p><strong>App version:</strong> ${escapeHtml(input.appVersion || "unknown")}</p>
<p><strong>App build:</strong> ${escapeHtml(input.appBuild || "unknown")}</p>
<p><strong>App commit:</strong> ${escapeHtml(input.appCommit || "unknown")}</p>
<p><strong>Bundle identifier:</strong> ${escapeHtml(
input.bundleIdentifier || "unknown",
)}</p>
<p><strong>macOS:</strong> ${escapeHtml(input.osVersion || "unknown")}</p>
<p><strong>Locale:</strong> ${escapeHtml(input.locale || "unknown")}</p>
${attachmentMarkup}
<h2 style="font-size:15px;margin:24px 0 8px">Message</h2>
<pre style="white-space:pre-wrap;font:13px/1.6 SFMono-Regular,Menlo,monospace;background:#f3f4f6;border-radius:10px;padding:12px">${escapeHtml(
input.message,
)}</pre>
</div>
`.trim();
}
function sanitizeFilename(fileName: string) {
const cleaned = fileName.replace(/[\r\n"]/g, "").trim();
return cleaned.length > 0 ? cleaned : "attachment";
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function jsonError(message: string, status: number) {
return NextResponse.json(
{ error: message },
{
status,
headers: {
"Cache-Control": "no-store",
},
},
);
}