From 0b9f5b6f5c1517178aad6d4e664d0d62661a8a12 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 12 Mar 2026 11:14:11 -0700 Subject: [PATCH 1/6] Replace Web3Forms with internal feedback emails --- .../[featureRequestId]/upvote/route.tsx | 9 +- .../internal/feature-requests/route.tsx | 40 ++++- .../api/latest/internal/feedback/route.tsx | 51 ++++++ .../src/lib/internal-feedback-emails.tsx | 150 ++++++++++++++++++ .../src/components/feedback-form.tsx | 31 ++-- .../stack-companion/feature-request-board.tsx | 57 ++++--- .../src/lib/internal-project-headers.ts | 14 ++ .../api/v1/internal/feedback.test.ts | 84 ++++++++++ 8 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/feedback/route.tsx create mode 100644 apps/backend/src/lib/internal-feedback-emails.tsx create mode 100644 apps/dashboard/src/lib/internal-project-headers.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts diff --git a/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx b/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx index 355148293e..fa15b41a09 100644 --- a/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx @@ -4,7 +4,9 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase"; -const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +function getFeaturebaseApiKey() { + return getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +} // POST /api/latest/internal/feature-requests/[featureRequestId]/upvote export const POST = createSmartRouteHandler({ @@ -36,7 +38,8 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth, params }) => { - if (!STACK_FEATUREBASE_API_KEY) { + const featurebaseApiKey = getFeaturebaseApiKey(); + if (!featurebaseApiKey) { throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); } @@ -52,7 +55,7 @@ export const POST = createSmartRouteHandler({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, body: JSON.stringify({ id: params.featureRequestId, diff --git a/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx b/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx index 8224c31de8..6d9d8e9d5b 100644 --- a/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx @@ -1,10 +1,13 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { sendFeatureRequestNotificationEmail } from "@/lib/internal-feedback-emails"; import { adaptSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase"; -const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +function getFeaturebaseApiKey() { + return getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +} // GET /api/latest/internal/feature-requests export const GET = createSmartRouteHandler({ @@ -16,6 +19,7 @@ export const GET = createSmartRouteHandler({ request: yupObject({ auth: yupObject({ type: adaptSchema, + tenancy: adaptSchema.defined(), user: adaptSchema.defined(), project: yupObject({ id: yupString().oneOf(["internal"]).defined(), @@ -43,7 +47,8 @@ export const GET = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth }) => { - if (!STACK_FEATUREBASE_API_KEY) { + const featurebaseApiKey = getFeaturebaseApiKey(); + if (!featurebaseApiKey) { throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); } @@ -59,7 +64,7 @@ export const GET = createSmartRouteHandler({ const response = await fetch('https://do.featurebase.app/v2/posts?limit=50&sortBy=upvotes:desc', { method: 'GET', headers: { - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, }); @@ -90,7 +95,7 @@ export const GET = createSmartRouteHandler({ const upvoteResponse = await fetch(`https://do.featurebase.app/v2/posts/upvoters?submissionId=${post.id}`, { method: 'GET', headers: { - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, }); @@ -132,6 +137,7 @@ export const POST = createSmartRouteHandler({ request: yupObject({ auth: yupObject({ type: adaptSchema, + tenancy: adaptSchema.defined(), user: adaptSchema.defined(), project: yupObject({ id: yupString().oneOf(["internal"]).defined(), @@ -156,7 +162,8 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth, body }) => { - if (!STACK_FEATUREBASE_API_KEY) { + const featurebaseApiKey = getFeaturebaseApiKey(); + if (!featurebaseApiKey) { throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); } @@ -189,7 +196,7 @@ export const POST = createSmartRouteHandler({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, body: JSON.stringify(featurebaseRequestBody), }); @@ -200,6 +207,25 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError(`Featurebase API error: ${data.error || 'Failed to create feature request'}`, { data }); } + try { + await sendFeatureRequestNotificationEmail({ + tenancy: auth.tenancy, + user: auth.user, + title: body.title, + content: body.content ?? null, + featureRequestId: data.id, + }); + } catch (error) { + captureError("feature-request-notification-email", new StackAssertionError( + "Feature request notification email failed after Featurebase post creation succeeded", + { + cause: error, + featureRequestId: data.id, + userId: auth.user.id, + }, + )); + } + return { statusCode: 200, bodyType: "json" as const, diff --git a/apps/backend/src/app/api/latest/internal/feedback/route.tsx b/apps/backend/src/app/api/latest/internal/feedback/route.tsx new file mode 100644 index 0000000000..a91cc302c9 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/feedback/route.tsx @@ -0,0 +1,51 @@ +import { sendSupportFeedbackEmail } from "@/lib/internal-feedback-emails"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Submit support feedback", + description: "Send a support feedback message to the internal Stack Auth inbox", + tags: ["Internal"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + user: adaptSchema.defined(), + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), + }).defined(), + body: yupObject({ + name: yupString().optional(), + email: emailSchema.defined().nonEmpty(), + message: yupString().defined().nonEmpty(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + }).defined(), + }), + async handler({ auth, body }) { + await sendSupportFeedbackEmail({ + tenancy: auth.tenancy, + user: auth.user, + name: body.name ?? null, + email: body.email, + message: body.message, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/internal-feedback-emails.tsx b/apps/backend/src/lib/internal-feedback-emails.tsx new file mode 100644 index 0000000000..582c60b82b --- /dev/null +++ b/apps/backend/src/lib/internal-feedback-emails.tsx @@ -0,0 +1,150 @@ +import { createTemplateComponentFromHtml } from "@/lib/email-rendering"; +import { getEmailConfig, normalizeEmail, sendEmailToMany } from "@/lib/emails"; +import { getNotificationCategoryByName } from "@/lib/notification-categories"; +import { Tenancy } from "@/lib/tenancies"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { throwErr, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { escapeHtml } from "@stackframe/stack-shared/dist/utils/html"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; + +const defaultRecipient = "team@stack-auth.com"; +const transactionalCategoryId = getNotificationCategoryByName("Transactional")?.id ?? throwErr("Transactional notification category not found"); + +function formatTextForHtml(text: string): string { + return escapeHtml(text).replace(/\n/g, "
"); +} + +function sanitizeSubject(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function getInternalFeedbackRecipients(): string[] { + const rawRecipients = getEnvVariable("STACK_INTERNAL_FEEDBACK_RECIPIENTS", defaultRecipient); + const recipients = rawRecipients.split(",").map((recipient) => recipient.trim()); + + if (recipients.some((recipient) => recipient.length === 0)) { + throw new StackAssertionError("STACK_INTERNAL_FEEDBACK_RECIPIENTS contains an empty recipient", { + rawRecipients, + }); + } + + return [...new Set(recipients.map((recipient) => normalizeEmail(recipient)))]; +} + +async function sendInternalOperationsEmail(options: { + tenancy: Tenancy, + subject: string, + htmlContent: string, +}) { + await getEmailConfig(options.tenancy); + + const recipients = getInternalFeedbackRecipients(); + const tsxSource = createTemplateComponentFromHtml(options.htmlContent); + + await sendEmailToMany({ + tenancy: options.tenancy, + recipients: recipients.map((recipient) => ({ type: "custom-emails" as const, emails: [recipient] })), + tsxSource, + extraVariables: {}, + themeId: null, + isHighPriority: true, + shouldSkipDeliverabilityCheck: true, + scheduledAt: new Date(), + createdWith: { type: "programmatic-call", templateId: null }, + overrideSubject: sanitizeSubject(options.subject), + overrideNotificationCategoryId: transactionalCategoryId, + }); +} + +export async function sendSupportFeedbackEmail(options: { + tenancy: Tenancy, + user: UsersCrud["Admin"]["Read"], + name: string | null, + email: string, + message: string, +}) { + const displayName = options.name ?? options.user.display_name ?? "Not provided"; + const htmlContent = ` +
+

Support feedback submission

+

Sender name: ${formatTextForHtml(displayName)}

+

Sender email: ${formatTextForHtml(options.email)}

+

Stack Auth user ID: ${formatTextForHtml(options.user.id)}

+

Stack Auth display name: ${formatTextForHtml(options.user.display_name ?? "Not provided")}

+
+

Message

+
+ ${formatTextForHtml(options.message)} +
+
+
+ `; + + await sendInternalOperationsEmail({ + tenancy: options.tenancy, + subject: `[Support] ${options.email}`, + htmlContent, + }); +} + +export async function sendFeatureRequestNotificationEmail(options: { + tenancy: Tenancy, + user: UsersCrud["Admin"]["Read"], + title: string, + content: string | null, + featureRequestId: string, +}) { + const featureRequestUrl = new URL(urlString`/p/${options.featureRequestId}`, "https://feedback.stack-auth.com").toString(); + const htmlContent = ` +
+

New feature request submitted

+

Title: ${formatTextForHtml(options.title)}

+

Featurebase post ID: ${formatTextForHtml(options.featureRequestId)}

+

Featurebase URL: ${escapeHtml(featureRequestUrl)}

+

Submitted by: ${formatTextForHtml(options.user.display_name ?? "Not provided")}

+

Submitted email: ${formatTextForHtml(options.user.primary_email ?? "Not provided")}

+

Stack Auth user ID: ${formatTextForHtml(options.user.id)}

+
+

Details

+
+ ${formatTextForHtml(options.content ?? "Not provided")} +
+
+
+ `; + + await sendInternalOperationsEmail({ + tenancy: options.tenancy, + subject: `[Feature Request] ${options.title}`, + htmlContent, + }); +} + +import.meta.vitest?.test("getInternalFeedbackRecipients()", ({ expect }) => { + // eslint-disable-next-line no-restricted-syntax + const previousValue = process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "TEAM@stack-auth.com, team@stack-auth.com , another@example.com"; + expect(getInternalFeedbackRecipients()).toEqual([ + "team@stack-auth.com", + "another@example.com", + ]); + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "valid@example.com, "; + expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient"); + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = ", "; + expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient"); + + if (previousValue === undefined) { + // eslint-disable-next-line no-restricted-syntax + delete process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; + } else { + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = previousValue; + } +}); diff --git a/apps/dashboard/src/components/feedback-form.tsx b/apps/dashboard/src/components/feedback-form.tsx index 3f8a64bcd1..0e80efe46c 100644 --- a/apps/dashboard/src/components/feedback-form.tsx +++ b/apps/dashboard/src/components/feedback-form.tsx @@ -1,4 +1,6 @@ import { Button } from "@/components/ui"; +import { getPublicEnvVar } from "@/lib/env"; +import { getInternalProjectHeaders } from "@/lib/internal-project-headers"; import { CheckCircleIcon, EnvelopeIcon, GithubLogoIcon, WarningCircleIcon } from "@phosphor-icons/react"; import { useUser } from "@stackframe/stack"; import { emailSchema } from "@stackframe/stack-shared/dist/schema-fields"; @@ -13,6 +15,7 @@ export function FeedbackForm() { const [submitting, setSubmitting] = useState(false); const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(''); + const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; const domainFormSchema = yup.object({ name: yup.string() @@ -36,26 +39,28 @@ export function FeedbackForm() { setErrorMessage(''); try { - const response = await fetch("https://api.web3forms.com/submit", { + if (user == null) { + throw new Error("Please sign in again and retry sending feedback."); + } + const authJson = await user.getAuthJson(); + const response = await fetch(`${baseUrl}/api/v1/internal/feedback`, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + ...getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: "application/json", + }), }, - body: JSON.stringify({ - ...values, - type: "feedback", - // This is the public access key, so no worries - access_key: '4f0fc468-c066-4e45-95c1-546fd652a44a', - }, null, 2), + body: JSON.stringify(values), }); if (!response.ok) { - throw new Error(`Failed to send feedback: ${response.status} ${response.statusText}`); + const responseText = await response.text(); + throw new Error(responseText || `Failed to send feedback: ${response.status} ${response.statusText}`); } - const result = await response.json(); - if (!result.success) { + const result: { success?: boolean, message?: string } = await response.json(); + if (result.success !== true) { throw new Error(result.message || 'Failed to send feedback'); } @@ -132,7 +137,7 @@ export function FeedbackForm() { form="feedback-form" className="w-full" loading={submitting} - disabled={submitting} + disabled={submitting || user == null} > Send Feedback diff --git a/apps/dashboard/src/components/stack-companion/feature-request-board.tsx b/apps/dashboard/src/components/stack-companion/feature-request-board.tsx index 9538df8d44..36fb0961a1 100644 --- a/apps/dashboard/src/components/stack-companion/feature-request-board.tsx +++ b/apps/dashboard/src/components/stack-companion/feature-request-board.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/ui'; import { getPublicEnvVar } from '@/lib/env'; +import { getInternalProjectHeaders } from '@/lib/internal-project-headers'; import { cn } from '@/lib/utils'; import { CaretUpIcon, CircleNotchIcon, LightbulbIcon, PaperPlaneTiltIcon, PlusIcon, XIcon } from '@phosphor-icons/react'; import { useUser } from '@stackframe/stack'; @@ -48,7 +49,7 @@ type CreateFeatureRequestResponse = { }; export function FeatureRequestBoard({}: FeatureRequestBoardProps) { - const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const user = useUser(); // Base URL for API requests const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; @@ -69,15 +70,19 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Fetch existing feature requests from secure backend const fetchFeatureRequests = useCallback(async () => { + if (user == null) { + setExistingRequests([]); + setUserUpvotes(new Set()); + setIsLoadingRequests(false); + return; + } + try { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests`, { - headers: { - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + }), }); if (response.ok) { @@ -118,6 +123,9 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Handle upvote const handleUpvote = async (postId: string) => { + if (user == null) { + throw new Error("Please sign in again and retry upvoting."); + } const wasUpvoted = userUpvotes.has(postId); if (wasUpvoted) return; // sadly Featurebase doesn't currently support unvoting via the API... @@ -142,13 +150,10 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests/${postId}/upvote`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: 'application/json', + }), body: JSON.stringify({}), }); @@ -195,6 +200,10 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Submit feature request via secure backend const submitFeatureRequest = async () => { + if (user == null) { + setSubmitStatus('error'); + return; + } if (!featureTitle.trim()) return; setIsSubmitting(true); @@ -212,13 +221,10 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: 'application/json', + }), body: JSON.stringify(requestBody) }); @@ -305,7 +311,7 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { onChange={(e) => setFeatureTitle(e.target.value)} placeholder="Brief description of your feature request..." className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" - disabled={isSubmitting} + disabled={isSubmitting || user == null} /> @@ -321,14 +327,14 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { placeholder="Provide more details about your feature request..." rows={3} className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none" - disabled={isSubmitting} + disabled={isSubmitting || user == null} /> {/* Submit Button */}