Skip to content

Commit 0b9f5b6

Browse files
committed
Replace Web3Forms with internal feedback emails
1 parent 485fa9d commit 0b9f5b6

8 files changed

Lines changed: 388 additions & 48 deletions

File tree

apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
44
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
55
import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase";
66

7-
const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", "");
7+
function getFeaturebaseApiKey() {
8+
return getEnvVariable("STACK_FEATUREBASE_API_KEY", "");
9+
}
810

911
// POST /api/latest/internal/feature-requests/[featureRequestId]/upvote
1012
export const POST = createSmartRouteHandler({
@@ -36,7 +38,8 @@ export const POST = createSmartRouteHandler({
3638
}).defined(),
3739
}),
3840
handler: async ({ auth, params }) => {
39-
if (!STACK_FEATUREBASE_API_KEY) {
41+
const featurebaseApiKey = getFeaturebaseApiKey();
42+
if (!featurebaseApiKey) {
4043
throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set");
4144
}
4245

@@ -52,7 +55,7 @@ export const POST = createSmartRouteHandler({
5255
method: 'POST',
5356
headers: {
5457
'Content-Type': 'application/json',
55-
'X-API-Key': STACK_FEATUREBASE_API_KEY,
58+
'X-API-Key': featurebaseApiKey,
5659
},
5760
body: JSON.stringify({
5861
id: params.featureRequestId,

apps/backend/src/app/api/latest/internal/feature-requests/route.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { sendFeatureRequestNotificationEmail } from "@/lib/internal-feedback-emails";
23
import { adaptSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
34
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
4-
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
5+
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
56
import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase";
67

7-
const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", "");
8+
function getFeaturebaseApiKey() {
9+
return getEnvVariable("STACK_FEATUREBASE_API_KEY", "");
10+
}
811

912
// GET /api/latest/internal/feature-requests
1013
export const GET = createSmartRouteHandler({
@@ -16,6 +19,7 @@ export const GET = createSmartRouteHandler({
1619
request: yupObject({
1720
auth: yupObject({
1821
type: adaptSchema,
22+
tenancy: adaptSchema.defined(),
1923
user: adaptSchema.defined(),
2024
project: yupObject({
2125
id: yupString().oneOf(["internal"]).defined(),
@@ -43,7 +47,8 @@ export const GET = createSmartRouteHandler({
4347
}).defined(),
4448
}),
4549
handler: async ({ auth }) => {
46-
if (!STACK_FEATUREBASE_API_KEY) {
50+
const featurebaseApiKey = getFeaturebaseApiKey();
51+
if (!featurebaseApiKey) {
4752
throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set");
4853
}
4954

@@ -59,7 +64,7 @@ export const GET = createSmartRouteHandler({
5964
const response = await fetch('https://do.featurebase.app/v2/posts?limit=50&sortBy=upvotes:desc', {
6065
method: 'GET',
6166
headers: {
62-
'X-API-Key': STACK_FEATUREBASE_API_KEY,
67+
'X-API-Key': featurebaseApiKey,
6368
},
6469
});
6570

@@ -90,7 +95,7 @@ export const GET = createSmartRouteHandler({
9095
const upvoteResponse = await fetch(`https://do.featurebase.app/v2/posts/upvoters?submissionId=${post.id}`, {
9196
method: 'GET',
9297
headers: {
93-
'X-API-Key': STACK_FEATUREBASE_API_KEY,
98+
'X-API-Key': featurebaseApiKey,
9499
},
95100
});
96101

@@ -132,6 +137,7 @@ export const POST = createSmartRouteHandler({
132137
request: yupObject({
133138
auth: yupObject({
134139
type: adaptSchema,
140+
tenancy: adaptSchema.defined(),
135141
user: adaptSchema.defined(),
136142
project: yupObject({
137143
id: yupString().oneOf(["internal"]).defined(),
@@ -156,7 +162,8 @@ export const POST = createSmartRouteHandler({
156162
}).defined(),
157163
}),
158164
handler: async ({ auth, body }) => {
159-
if (!STACK_FEATUREBASE_API_KEY) {
165+
const featurebaseApiKey = getFeaturebaseApiKey();
166+
if (!featurebaseApiKey) {
160167
throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set");
161168
}
162169

@@ -189,7 +196,7 @@ export const POST = createSmartRouteHandler({
189196
method: 'POST',
190197
headers: {
191198
'Content-Type': 'application/json',
192-
'X-API-Key': STACK_FEATUREBASE_API_KEY,
199+
'X-API-Key': featurebaseApiKey,
193200
},
194201
body: JSON.stringify(featurebaseRequestBody),
195202
});
@@ -200,6 +207,25 @@ export const POST = createSmartRouteHandler({
200207
throw new StackAssertionError(`Featurebase API error: ${data.error || 'Failed to create feature request'}`, { data });
201208
}
202209

210+
try {
211+
await sendFeatureRequestNotificationEmail({
212+
tenancy: auth.tenancy,
213+
user: auth.user,
214+
title: body.title,
215+
content: body.content ?? null,
216+
featureRequestId: data.id,
217+
});
218+
} catch (error) {
219+
captureError("feature-request-notification-email", new StackAssertionError(
220+
"Feature request notification email failed after Featurebase post creation succeeded",
221+
{
222+
cause: error,
223+
featureRequestId: data.id,
224+
userId: auth.user.id,
225+
},
226+
));
227+
}
228+
203229
return {
204230
statusCode: 200,
205231
bodyType: "json" as const,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { sendSupportFeedbackEmail } from "@/lib/internal-feedback-emails";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
5+
export const POST = createSmartRouteHandler({
6+
metadata: {
7+
summary: "Submit support feedback",
8+
description: "Send a support feedback message to the internal Stack Auth inbox",
9+
tags: ["Internal"],
10+
},
11+
request: yupObject({
12+
auth: yupObject({
13+
type: clientOrHigherAuthTypeSchema,
14+
tenancy: adaptSchema.defined(),
15+
user: adaptSchema.defined(),
16+
project: yupObject({
17+
id: yupString().oneOf(["internal"]).defined(),
18+
}).defined(),
19+
}).defined(),
20+
body: yupObject({
21+
name: yupString().optional(),
22+
email: emailSchema.defined().nonEmpty(),
23+
message: yupString().defined().nonEmpty(),
24+
}).defined(),
25+
method: yupString().oneOf(["POST"]).defined(),
26+
}),
27+
response: yupObject({
28+
statusCode: yupNumber().oneOf([200]).defined(),
29+
bodyType: yupString().oneOf(["json"]).defined(),
30+
body: yupObject({
31+
success: yupBoolean().oneOf([true]).defined(),
32+
}).defined(),
33+
}),
34+
async handler({ auth, body }) {
35+
await sendSupportFeedbackEmail({
36+
tenancy: auth.tenancy,
37+
user: auth.user,
38+
name: body.name ?? null,
39+
email: body.email,
40+
message: body.message,
41+
});
42+
43+
return {
44+
statusCode: 200,
45+
bodyType: "json",
46+
body: {
47+
success: true,
48+
},
49+
};
50+
},
51+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { createTemplateComponentFromHtml } from "@/lib/email-rendering";
2+
import { getEmailConfig, normalizeEmail, sendEmailToMany } from "@/lib/emails";
3+
import { getNotificationCategoryByName } from "@/lib/notification-categories";
4+
import { Tenancy } from "@/lib/tenancies";
5+
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
6+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
7+
import { throwErr, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
8+
import { escapeHtml } from "@stackframe/stack-shared/dist/utils/html";
9+
import { urlString } from "@stackframe/stack-shared/dist/utils/urls";
10+
11+
const defaultRecipient = "team@stack-auth.com";
12+
const transactionalCategoryId = getNotificationCategoryByName("Transactional")?.id ?? throwErr("Transactional notification category not found");
13+
14+
function formatTextForHtml(text: string): string {
15+
return escapeHtml(text).replace(/\n/g, "<br />");
16+
}
17+
18+
function sanitizeSubject(value: string): string {
19+
return value.replace(/\s+/g, " ").trim();
20+
}
21+
22+
export function getInternalFeedbackRecipients(): string[] {
23+
const rawRecipients = getEnvVariable("STACK_INTERNAL_FEEDBACK_RECIPIENTS", defaultRecipient);
24+
const recipients = rawRecipients.split(",").map((recipient) => recipient.trim());
25+
26+
if (recipients.some((recipient) => recipient.length === 0)) {
27+
throw new StackAssertionError("STACK_INTERNAL_FEEDBACK_RECIPIENTS contains an empty recipient", {
28+
rawRecipients,
29+
});
30+
}
31+
32+
return [...new Set(recipients.map((recipient) => normalizeEmail(recipient)))];
33+
}
34+
35+
async function sendInternalOperationsEmail(options: {
36+
tenancy: Tenancy,
37+
subject: string,
38+
htmlContent: string,
39+
}) {
40+
await getEmailConfig(options.tenancy);
41+
42+
const recipients = getInternalFeedbackRecipients();
43+
const tsxSource = createTemplateComponentFromHtml(options.htmlContent);
44+
45+
await sendEmailToMany({
46+
tenancy: options.tenancy,
47+
recipients: recipients.map((recipient) => ({ type: "custom-emails" as const, emails: [recipient] })),
48+
tsxSource,
49+
extraVariables: {},
50+
themeId: null,
51+
isHighPriority: true,
52+
shouldSkipDeliverabilityCheck: true,
53+
scheduledAt: new Date(),
54+
createdWith: { type: "programmatic-call", templateId: null },
55+
overrideSubject: sanitizeSubject(options.subject),
56+
overrideNotificationCategoryId: transactionalCategoryId,
57+
});
58+
}
59+
60+
export async function sendSupportFeedbackEmail(options: {
61+
tenancy: Tenancy,
62+
user: UsersCrud["Admin"]["Read"],
63+
name: string | null,
64+
email: string,
65+
message: string,
66+
}) {
67+
const displayName = options.name ?? options.user.display_name ?? "Not provided";
68+
const htmlContent = `
69+
<div style="font-family: Arial, sans-serif; max-width: 720px; margin: 0 auto; padding: 24px; color: #1f2937;">
70+
<h2 style="margin: 0 0 20px;">Support feedback submission</h2>
71+
<p><strong>Sender name:</strong> ${formatTextForHtml(displayName)}</p>
72+
<p><strong>Sender email:</strong> ${formatTextForHtml(options.email)}</p>
73+
<p><strong>Stack Auth user ID:</strong> ${formatTextForHtml(options.user.id)}</p>
74+
<p><strong>Stack Auth display name:</strong> ${formatTextForHtml(options.user.display_name ?? "Not provided")}</p>
75+
<div style="margin-top: 24px;">
76+
<p style="margin-bottom: 8px;"><strong>Message</strong></p>
77+
<div style="padding: 16px; border: 1px solid #d1d5db; border-radius: 8px; background: #f9fafb; white-space: normal;">
78+
${formatTextForHtml(options.message)}
79+
</div>
80+
</div>
81+
</div>
82+
`;
83+
84+
await sendInternalOperationsEmail({
85+
tenancy: options.tenancy,
86+
subject: `[Support] ${options.email}`,
87+
htmlContent,
88+
});
89+
}
90+
91+
export async function sendFeatureRequestNotificationEmail(options: {
92+
tenancy: Tenancy,
93+
user: UsersCrud["Admin"]["Read"],
94+
title: string,
95+
content: string | null,
96+
featureRequestId: string,
97+
}) {
98+
const featureRequestUrl = new URL(urlString`/p/${options.featureRequestId}`, "https://feedback.stack-auth.com").toString();
99+
const htmlContent = `
100+
<div style="font-family: Arial, sans-serif; max-width: 720px; margin: 0 auto; padding: 24px; color: #1f2937;">
101+
<h2 style="margin: 0 0 20px;">New feature request submitted</h2>
102+
<p><strong>Title:</strong> ${formatTextForHtml(options.title)}</p>
103+
<p><strong>Featurebase post ID:</strong> ${formatTextForHtml(options.featureRequestId)}</p>
104+
<p><strong>Featurebase URL:</strong> <a href="${escapeHtml(featureRequestUrl)}">${escapeHtml(featureRequestUrl)}</a></p>
105+
<p><strong>Submitted by:</strong> ${formatTextForHtml(options.user.display_name ?? "Not provided")}</p>
106+
<p><strong>Submitted email:</strong> ${formatTextForHtml(options.user.primary_email ?? "Not provided")}</p>
107+
<p><strong>Stack Auth user ID:</strong> ${formatTextForHtml(options.user.id)}</p>
108+
<div style="margin-top: 24px;">
109+
<p style="margin-bottom: 8px;"><strong>Details</strong></p>
110+
<div style="padding: 16px; border: 1px solid #d1d5db; border-radius: 8px; background: #f9fafb; white-space: normal;">
111+
${formatTextForHtml(options.content ?? "Not provided")}
112+
</div>
113+
</div>
114+
</div>
115+
`;
116+
117+
await sendInternalOperationsEmail({
118+
tenancy: options.tenancy,
119+
subject: `[Feature Request] ${options.title}`,
120+
htmlContent,
121+
});
122+
}
123+
124+
import.meta.vitest?.test("getInternalFeedbackRecipients()", ({ expect }) => {
125+
// eslint-disable-next-line no-restricted-syntax
126+
const previousValue = process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS;
127+
128+
// eslint-disable-next-line no-restricted-syntax
129+
process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "TEAM@stack-auth.com, team@stack-auth.com , another@example.com";
130+
expect(getInternalFeedbackRecipients()).toEqual([
131+
"team@stack-auth.com",
132+
"another@example.com",
133+
]);
134+
135+
// eslint-disable-next-line no-restricted-syntax
136+
process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "valid@example.com, ";
137+
expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient");
138+
139+
// eslint-disable-next-line no-restricted-syntax
140+
process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = ", ";
141+
expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient");
142+
143+
if (previousValue === undefined) {
144+
// eslint-disable-next-line no-restricted-syntax
145+
delete process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS;
146+
} else {
147+
// eslint-disable-next-line no-restricted-syntax
148+
process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = previousValue;
149+
}
150+
});

0 commit comments

Comments
 (0)