Send email route and notification settings page (#717)

This commit is contained in:
BilalG1 2025-07-01 19:17:53 -07:00 committed by GitHub
parent dfae043457
commit 61d0adb7a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1154 additions and 10 deletions

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "UserNotificationPreference" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"projectUserId" UUID NOT NULL,
"notificationCategoryId" UUID NOT NULL,
"enabled" BOOLEAN NOT NULL,
CONSTRAINT "UserNotificationPreference_pkey" PRIMARY KEY ("tenancyId","id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserNotificationPreference_tenancyId_projectUserId_notifica_key" ON "UserNotificationPreference"("tenancyId", "projectUserId", "notificationCategoryId");
-- AddForeignKey
ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserNotificationPreference" ADD CONSTRAINT "UserNotificationPreference_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -51,14 +51,15 @@ model Tenancy {
organizationId String? @db.Uuid
hasNoOrganization BooleanTrue?
teams Team[] @relation("TenancyTeams")
projectUsers ProjectUser[] @relation("TenancyProjectUsers")
authMethods AuthMethod[] @relation("TenancyAuthMethods")
contactChannels ContactChannel[] @relation("TenancyContactChannels")
connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts")
SentEmail SentEmail[]
cliAuthAttempts CliAuthAttempt[]
projectApiKey ProjectApiKey[]
teams Team[] @relation("TenancyTeams")
projectUsers ProjectUser[] @relation("TenancyProjectUsers")
authMethods AuthMethod[] @relation("TenancyAuthMethods")
contactChannels ContactChannel[] @relation("TenancyContactChannels")
connectedAccounts ConnectedAccount[] @relation("TenancyConnectedAccounts")
SentEmail SentEmail[]
cliAuthAttempts CliAuthAttempt[]
projectApiKey ProjectApiKey[]
userNotificationPreferences UserNotificationPreference[]
@@unique([projectId, branchId, organizationId])
@@unique([projectId, branchId, hasNoOrganization])
@ -192,6 +193,7 @@ model ProjectUser {
contactChannels ContactChannel[]
authMethods AuthMethod[]
connectedAccounts ConnectedAccount[]
userNotificationPreferences UserNotificationPreference[]
// some backlinks for the unique constraints on some auth methods
passwordAuthMethod PasswordAuthMethod[]
@ -736,3 +738,17 @@ model CliAuthAttempt {
@@id([tenancyId, id])
}
model UserNotificationPreference {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
projectUserId String @db.Uuid
notificationCategoryId String @db.Uuid
enabled Boolean
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
@@id([tenancyId, id])
@@unique([tenancyId, projectUserId, notificationCategoryId])
}

View File

@ -0,0 +1,3 @@
import { notificationPreferencesCrudHandlers } from "../../crud";
export const PATCH = notificationPreferencesCrudHandlers.updateHandler;

View File

@ -0,0 +1,3 @@
import { notificationPreferencesCrudHandlers } from "../crud";
export const GET = notificationPreferencesCrudHandlers.listHandler;

View File

@ -0,0 +1,103 @@
import { listNotificationCategories } from "@/lib/notification-categories";
import { ensureUserExists } from "@/lib/request-checks";
import { prismaClient } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { notificationPreferenceCrud, NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
export const notificationPreferencesCrudHandlers = createLazyProxy(() => createCrudHandlers(notificationPreferenceCrud, {
paramsSchema: yupObject({
user_id: userIdOrMeSchema.defined(),
notification_category_id: yupString().uuid().optional(),
}),
onUpdate: async ({ auth, params, data }) => {
const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired())) : params.user_id;
const notificationCategories = listNotificationCategories();
const notificationCategory = notificationCategories.find(c => c.id === params.notification_category_id);
if (!notificationCategory || !params.notification_category_id) {
throw new StatusError(404, "Notification category not found");
}
if (auth.type === 'client') {
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
}
if (userId !== auth.user.id) {
throw new StatusError(StatusError.Forbidden, "You can only manage your own notification preferences");
}
}
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
const notificationPreference = await prismaClient.userNotificationPreference.upsert({
where: {
tenancyId_projectUserId_notificationCategoryId: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
notificationCategoryId: params.notification_category_id,
},
},
update: {
enabled: data.enabled,
},
create: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
notificationCategoryId: params.notification_category_id,
enabled: data.enabled,
},
});
return {
notification_category_id: notificationPreference.notificationCategoryId,
notification_category_name: notificationCategory.name,
enabled: notificationPreference.enabled,
can_disable: notificationCategory.can_disable,
};
},
onList: async ({ auth, params }) => {
const userId = params.user_id === 'me' ? (auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired)) : params.user_id;
if (!userId) {
throw new KnownErrors.UserAuthenticationRequired;
}
if (auth.type === 'client') {
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired;
}
if (userId && userId !== auth.user.id) {
throw new StatusError(StatusError.Forbidden, "You can only view your own notification preferences");
}
}
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
const notificationPreferences = await prismaClient.userNotificationPreference.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
},
select: {
notificationCategoryId: true,
enabled: true,
},
});
const notificationCategories = listNotificationCategories();
const items: NotificationPreferenceCrud["Client"]["Read"][] = notificationCategories.map(category => {
const preference = notificationPreferences.find(p => p.notificationCategoryId === category.id);
return {
notification_category_id: category.id,
notification_category_name: category.name,
enabled: preference?.enabled ?? category.default_enabled,
can_disable: category.can_disable,
};
});
return {
items,
is_paginated: false,
};
},
}));

View File

@ -0,0 +1,87 @@
import { getEmailConfig, sendEmail } from "@/lib/emails";
import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { getUser } from "../../users/crud";
import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
user_id: yupString().defined(),
html: yupString().defined(),
subject: yupString().defined(),
notification_category_name: yupString().defined(),
}),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
user_email: yupString().defined(),
}).defined(),
}),
handler: async ({ body, auth }) => {
if (auth.tenancy.config.email_config.type === "shared") {
throw new StatusError(400, "Cannot send custom emails when using shared email config");
}
const user = await getUser({ userId: body.user_id, tenancyId: auth.tenancy.id });
if (!user) {
throw new StatusError(404, "User not found");
}
if (!user.primary_email) {
throw new StatusError(400, "User does not have a primary email");
}
const notificationCategory = getNotificationCategoryByName(body.notification_category_name);
if (!notificationCategory) {
throw new StatusError(404, "Notification category not found");
}
const isNotificationEnabled = await hasNotificationEnabled(auth.tenancy.id, user.id, notificationCategory.id);
if (!isNotificationEnabled) {
throw new StatusError(400, "User has disabled notifications for this category");
}
let html = body.html;
if (notificationCategory.can_disable) {
const { code } = await unsubscribeLinkVerificationCodeHandler.createCode({
tenancy: auth.tenancy,
method: {},
data: {
user_id: user.id,
notification_category_id: notificationCategory.id,
},
callbackUrl: undefined
});
const unsubscribeLink = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
unsubscribeLink.pathname = "/api/v1/emails/unsubscribe-link";
unsubscribeLink.searchParams.set("code", code);
html += `<br /><a href="${unsubscribeLink.toString()}">Click here to unsubscribe</a>`;
}
await sendEmail({
tenancyId: auth.tenancy.id,
emailConfig: await getEmailConfig(auth.tenancy),
to: user.primary_email,
subject: body.subject,
html,
});
return {
statusCode: 200,
bodyType: 'json',
body: {
user_email: user.primary_email,
},
};
},
});

View File

@ -0,0 +1,66 @@
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { prismaClient } from "@/prisma-client";
import { VerificationCodeType } from "@prisma/client";
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (!code || code.length !== 45)
return new Response('Invalid code', { status: 400 });
const codeLower = code.toLowerCase();
const verificationCode = await prismaClient.verificationCode.findFirst({
where: {
code: codeLower,
type: VerificationCodeType.ONE_TIME_PASSWORD,
},
});
if (!verificationCode) throw new KnownErrors.VerificationCodeNotFound();
if (verificationCode.expiresAt < new Date()) throw new KnownErrors.VerificationCodeExpired();
if (verificationCode.usedAt) {
return new Response('<p>You have already unsubscribed from this notification group</p>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
}
const { user_id, notification_category_id } = verificationCode.data as { user_id: string, notification_category_id: string };
await prismaClient.verificationCode.update({
where: {
projectId_branchId_code: {
projectId: verificationCode.projectId,
branchId: verificationCode.branchId,
code: codeLower,
},
},
data: { usedAt: new Date() },
});
const tenancy = await getSoleTenancyFromProjectBranch(verificationCode.projectId, verificationCode.branchId);
await prismaClient.userNotificationPreference.upsert({
where: {
tenancyId_projectUserId_notificationCategoryId: {
tenancyId: tenancy.id,
projectUserId: user_id,
notificationCategoryId: notification_category_id,
},
},
update: {
enabled: false,
},
create: {
tenancyId: tenancy.id,
projectUserId: user_id,
notificationCategoryId: notification_category_id,
enabled: false,
},
});
return new Response('<p>Successfully unsubscribed from notification group</p>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
}

View File

@ -0,0 +1,15 @@
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
export const unsubscribeLinkVerificationCodeHandler = createVerificationCodeHandler({
type: VerificationCodeType.ONE_TIME_PASSWORD,
data: yupObject({
user_id: yupString().defined(),
notification_category_id: yupString().defined(),
}),
// @ts-expect-error handler functions are not used for this verificationCodeHandler
async handler() {
return null;
},
});

View File

@ -332,7 +332,7 @@ export async function sendEmailFromTemplate(options: {
});
}
async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
export async function getEmailConfig(tenancy: Tenancy): Promise<EmailConfig> {
const projectEmailConfig = tenancy.config.email_config;
if (projectEmailConfig.type === 'shared') {

View File

@ -0,0 +1,59 @@
import { Tenancy } from "@/lib/tenancies";
import { prismaClient } from "@/prisma-client";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { signInVerificationCodeHandler } from "../app/api/latest/auth/otp/sign-in/verification-code-handler";
// For now, we only have two hardcoded notification categories. TODO: query from database instead and create UI to manage them in dashboard
export const listNotificationCategories = () => {
return [
{
id: "7bb82d33-2f54-4a3d-9d23-82739e0d66ef",
name: "Transactional",
default_enabled: true,
can_disable: false,
},
{
id: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c",
name: "Marketing",
default_enabled: true,
can_disable: true,
},
];
};
export const getNotificationCategoryByName = (name: string) => {
return listNotificationCategories().find((category) => category.name === name);
};
export const hasNotificationEnabled = async (tenancyId: string, userId: string, notificationCategoryId: string) => {
const notificationCategory = listNotificationCategories().find((category) => category.id === notificationCategoryId);
if (!notificationCategory) {
throw new StackAssertionError('Invalid notification category id', { notificationCategoryId });
}
const userNotificationPreference = await prismaClient.userNotificationPreference.findFirst({
where: {
tenancyId,
projectUserId: userId,
notificationCategoryId,
},
});
if (!userNotificationPreference) {
return notificationCategory.default_enabled;
}
return userNotificationPreference.enabled;
};
export const generateUnsubscribeLink = async (tenancy: Tenancy, userId: string, notificationCategoryId: string) => {
const { code } = await signInVerificationCodeHandler.createCode({
tenancy,
expiresInMs: 1000 * 60 * 60 * 24 * 30,
data: {},
method: {
email: "test@test.com",
type: "standard",
},
callbackUrl: undefined,
});
return `${getEnvVariable("NEXT_PUBLIC_STACK_API_URL")}/api/v1/emails/unsubscribe-link?token=${code}&notification_category_id=${notificationCategoryId}`;
};

View File

@ -0,0 +1,144 @@
import { randomUUID } from "crypto";
import { describe } from "vitest";
import { it } from "../../../../helpers";
import { Auth, niceBackendFetch } from "../../../backend-helpers";
describe("invalid requests", () => {
it("should return 401 when invalid authorization is provided", async ({ expect }) => {
const response = await niceBackendFetch(
`/api/v1/emails/notification-preference/me/${randomUUID()}`,
{
method: "PATCH",
accessType: "client",
body: {
enabled: true,
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "CANNOT_GET_OWN_USER_WITHOUT_USER",
"error": "You have specified 'me' as a userId, but did not provide authentication for a user.",
},
"headers": Headers {
"x-stack-known-error": "CANNOT_GET_OWN_USER_WITHOUT_USER",
<some fields may have been hidden>,
},
}
`);
});
it("should return 404 when invalid notification category id is provided", async ({ expect }) => {
await Auth.Otp.signIn();
const response = await niceBackendFetch(
`/api/v1/emails/notification-preference/me/${randomUUID()}`,
{
method: "PATCH",
accessType: "client",
body: {
enabled: true,
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": "Notification category not found",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
});
it("lists default notification preferences", async ({ expect }) => {
await Auth.Otp.signIn();
const response = await niceBackendFetch(
"/api/v1/emails/notification-preference/me",
{
method: "GET",
accessType: "client",
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"can_disable": false,
"enabled": true,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Transactional",
},
{
"can_disable": true,
"enabled": true,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Marketing",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("updates notification preferences", async ({ expect }) => {
await Auth.Otp.signIn();
const response = await niceBackendFetch(
"/api/v1/emails/notification-preference/me/4f6f8873-3d04-46bd-8bef-18338b1a1b4c",
{
method: "PATCH",
accessType: "client",
body: {
enabled: false,
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"can_disable": true,
"enabled": false,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Marketing",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const listPreferencesResponse = await niceBackendFetch(
"/api/v1/emails/notification-preference/me",
{
method: "GET",
accessType: "client",
}
);
expect(listPreferencesResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"can_disable": false,
"enabled": true,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Transactional",
},
{
"can_disable": true,
"enabled": false,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Marketing",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -0,0 +1,272 @@
import { randomUUID } from "crypto";
import { describe } from "vitest";
import { it } from "../../../../helpers";
import { niceBackendFetch, Project, User } from "../../../backend-helpers";
const testEmailConfig = {
type: "standard",
host: "localhost",
port: 2500,
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
} as const;
describe("invalid requests", () => {
it("should return 401 when invalid access type is provided", async ({ expect }) => {
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "client",
body: {
user_id: randomUUID(),
html: "<p>Test email</p>",
subject: "Test Subject",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "INSUFFICIENT_ACCESS_TYPE",
"details": {
"actual_access_type": "client",
"allowed_access_types": [
"server",
"admin",
],
},
"error": "The x-stack-access-type header must be 'server' or 'admin', but was 'client'.",
},
"headers": Headers {
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
<some fields may have been hidden>,
},
}
`);
});
it("should return 404 when user is not found", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: randomUUID(),
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": "User not found",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 400 when using shared email config", async ({ expect }) => {
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: "test@example.com",
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Cannot send custom emails when using shared email config",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 404 when invalid notification category name is provided", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: "test@example.com",
},
});
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Invalid",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": "Notification category not found",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
});
it("should return 400 when user has disabled notifications for the category", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const user = await User.create();
// Disable notifications for Marketing category
const disableNotificationsResponse = await niceBackendFetch(`/api/v1/emails/notification-preference/${user.userId}/4f6f8873-3d04-46bd-8bef-18338b1a1b4c`, {
method: "PATCH",
accessType: "server",
body: {
enabled: false,
},
});
expect(disableNotificationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"can_disable": true,
"enabled": false,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Marketing",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "User has disabled notifications for this category",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 400 when user does not have a primary email", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {},
});
expect(createUserResponse.status).toBe(201);
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: createUserResponse.body.id,
html: "<p>Test email</p>",
subject: "Test Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "User does not have a primary email",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should return 200 and send email successfully", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: testEmailConfig,
},
});
const user = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the email was actually sent by checking the mailbox
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toContain("<h1>Test Email</h1>");
expect(sentEmail!.body?.html).toContain("<p>This is a test email with HTML content.</p>");
});

View File

@ -0,0 +1,133 @@
import { it } from "../../../../helpers";
import { niceBackendFetch, Project, User } from "../../../backend-helpers";
it("unsubscribe link should be sent and update notification preference", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: {
type: "standard",
host: "localhost",
port: 2500,
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
},
},
});
const user = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Marketing",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the email was actually sent by checking the mailbox
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toMatch(/<h1>Test Email<\/h1><p>This is a test email with HTML content\.<\/p><br \/><a href="http:\/\/localhost:8102\/api\/v1\/emails\/unsubscribe-link\?code=[a-zA-Z0-9]+">Click here to unsubscribe<\/a>/);
// Extract the unsubscribe link and fetch it
const unsubscribeLinkMatch = sentEmail!.body?.html.match(/href="([^"]+)"/);
expect(unsubscribeLinkMatch).toBeDefined();
const unsubscribeUrl = unsubscribeLinkMatch![1];
const unsubscribeResponse = await niceBackendFetch(unsubscribeUrl, {
method: "GET",
accessType: "client",
});
expect(unsubscribeResponse.status).toBe(200);
expect(unsubscribeResponse.body).toBe("<p>Successfully unsubscribed from notification group</p>");
const listPreferencesResponse = await niceBackendFetch(
`/api/v1/emails/notification-preference/${user.userId}`,
{
method: "GET",
accessType: "admin",
}
);
expect(listPreferencesResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"can_disable": false,
"enabled": true,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Transactional",
},
{
"can_disable": true,
"enabled": false,
"notification_category_id": "<stripped UUID>",
"notification_category_name": "Marketing",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("unsubscribe link should not be sent for emails with transactional notification category", async ({ expect }) => {
await Project.createAndSwitch({
display_name: "Test Successful Email Project",
config: {
email_config: {
type: "standard",
host: "localhost",
port: 2500,
username: "test",
password: "test",
sender_name: "Test Project",
sender_email: "test@example.com",
},
},
});
const user = await User.create();
const response = await niceBackendFetch(
"/api/v1/emails/send-email",
{
method: "POST",
accessType: "server",
body: {
user_id: user.userId,
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
subject: "Custom Test Email Subject",
notification_category_name: "Transactional",
}
}
);
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "user_email": "unindexed-mailbox--<stripped UUID>@stack-generated.example.com" },
"headers": Headers { <some fields may have been hidden> },
}
`);
const messages = await user.mailbox.fetchMessages();
const sentEmail = messages.find(msg => msg.subject === "Custom Test Email Subject");
expect(sentEmail).toBeDefined();
expect(sentEmail!.body?.html).toMatchInlineSnapshot(`"<h1>Test Email</h1><p>This is a test email with HTML content.</p>\\n"`);
});

View File

@ -15,6 +15,7 @@ import { Result } from "../utils/results";
import { deindent } from '../utils/strings';
import { ContactChannelsCrud } from './crud/contact-channels';
import { CurrentUserCrud } from './crud/current-user';
import { NotificationPreferenceCrud } from './crud/notification-preferences';
import { ConnectedAccountAccessTokenCrud } from './crud/oauth';
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateInputSchema, teamApiKeysCreateOutputSchema, userApiKeysCreateInputSchema, userApiKeysCreateOutputSchema } from './crud/project-api-keys';
import { ProjectPermissionsCrud } from './crud/project-permissions';
@ -1636,5 +1637,37 @@ export class StackClientInterface {
}
return await result.data.json();
}
async listNotificationCategories(
session: InternalSession,
): Promise<NotificationPreferenceCrud['Client']['Read'][]> {
const response = await this.sendClientRequest(
`/emails/notification-preference/me`,
{},
session,
);
const result = await response.json() as NotificationPreferenceCrud['Client']['List'];
return result.items;
}
async setNotificationsEnabled(
notificationCategoryId: string,
enabled: boolean,
session: InternalSession,
): Promise<void> {
await this.sendClientRequest(
`/emails/notification-preference/me/${notificationCategoryId}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
enabled,
}),
},
session,
);
}
}

View File

@ -0,0 +1,21 @@
import { createCrud, CrudTypeOf } from "../../crud";
import { yupBoolean, yupObject, yupString } from "../../schema-fields";
const notificationPreferenceReadSchema = yupObject({
notification_category_id: yupString().defined(),
notification_category_name: yupString().defined(),
enabled: yupBoolean().defined(),
can_disable: yupBoolean().defined(),
}).defined();
const notificationPreferenceUpdateSchema = yupObject({
enabled: yupBoolean().defined(),
}).defined();
export const notificationPreferenceCrud = createCrud({
clientReadSchema: notificationPreferenceReadSchema,
clientUpdateSchema: notificationPreferenceUpdateSchema,
});
export type NotificationPreferenceCrud = CrudTypeOf<typeof notificationPreferenceCrud>;

View File

@ -10,6 +10,7 @@ import {
} from "./client-interface";
import { ContactChannelsCrud } from "./crud/contact-channels";
import { CurrentUserCrud } from "./crud/current-user";
import { NotificationPreferenceCrud } from "./crud/notification-preferences";
import { ConnectedAccountAccessTokenCrud } from "./crud/oauth";
import { ProjectPermissionsCrud } from "./crud/project-permissions";
import { SessionsCrud } from "./crud/sessions";
@ -569,6 +570,40 @@ export class StackServerInterface extends StackClientInterface {
return json.items;
}
async listServerNotificationCategories(
userId: string,
): Promise<NotificationPreferenceCrud['Server']['Read'][]> {
const response = await this.sendServerRequest(
urlString`/emails/notification-preference/${userId}`,
{
method: "GET",
},
null,
);
const json = await response.json() as NotificationPreferenceCrud['Server']['List'];
return json.items;
}
async setServerNotificationsEnabled(
userId: string,
notificationCategoryId: string,
enabled: boolean,
): Promise<void> {
await this.sendServerRequest(
urlString`/emails/notification-preference/${userId}/${notificationCategoryId}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
enabled,
}),
},
null,
);
}
async sendServerContactChannelVerificationEmail(
userId: string,
contactChannelId: string,

View File

@ -11,6 +11,7 @@ import { useTranslation } from "../lib/translations";
import { ActiveSessionsPage } from "./account-settings/active-sessions/active-sessions-page";
import { ApiKeysPage } from "./account-settings/api-keys/api-keys-page";
import { EmailsAndAuthPage } from './account-settings/email-and-auth/email-and-auth-page';
import { NotificationsPage } from './account-settings/notifications/notifications-page';
import { ProfilePage } from "./account-settings/profile-page/profile-page";
import { SettingsPage } from './account-settings/settings/settings-page';
import { TeamCreationPage } from './account-settings/teams/team-creation-page';
@ -100,6 +101,15 @@ export function AccountSettings(props: {
<EmailsAndAuthPage mockMode={!!props.mockUser}/>
</Suspense>,
},
{
title: t('Notifications'),
type: 'item',
id: 'notifications',
icon: <Icon name="Bell"/>,
content: <Suspense fallback={<NotificationsPageSkeleton/>}>
<NotificationsPage/>
</Suspense>,
},
{
title: t('Active Sessions'),
type: 'item',
@ -219,3 +229,10 @@ function TeamCreationSkeleton() {
<Skeleton className="h-9 w-full mt-1"/>
</PageLayout>;
}
function NotificationsPageSkeleton() {
return <PageLayout>
<Skeleton className="h-9 w-full mt-1"/>
<Skeleton className="h-9 w-full mt-1"/>
</PageLayout>;
}

View File

@ -0,0 +1,40 @@
import { useUser } from "../../../lib/hooks";
import { useTranslation } from "../../../lib/translations";
import { PageLayout } from "../page-layout";
import { Switch } from "@stackframe/stack-ui";
import { Separator, Typography } from "@stackframe/stack-ui";
export function NotificationsPage() {
const { t } = useTranslation();
const user = useUser({ or: 'redirect' });
const notificationCategories = user.useNotificationCategories();
return (
<PageLayout>
<Separator />
<div className='flex flex-col gap-2'>
<div className='sm:flex-1 flex flex-col justify-center pb-2'>
<Typography className="font-medium">
{t('Choose which emails you want to receive')}
</Typography>
</div>
{notificationCategories.map((category) => (
<div key={category.id} className="flex justify-start gap-4 items-center">
<Switch
checked={category.enabled}
onCheckedChange={(value) => void category.setEnabled(value)}
disabled={!category.canDisable}
/>
<Typography>{category.name}</Typography>
{!category.canDisable && (
<Typography variant='secondary' type='footnote'>
(cannot be disabled)
</Typography>
)}
</div>
))}
</div>
</PageLayout>
);
}

View File

@ -11,6 +11,7 @@ import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/
import { TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
@ -34,6 +35,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ContactChannel, ContactChannelCreateOptions, ContactChannelUpdateOptions, contactChannelCreateOptionsToCrud, contactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, Team, TeamCreateOptions, TeamInvitation, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
@ -191,6 +193,13 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
);
private readonly _notificationCategoriesCache = createCacheBySession<[], NotificationPreferenceCrud['Client']['Read'][]>(
async (session) => {
const results = await this._interface.listNotificationCategories(session);
return results as NotificationPreferenceCrud['Client']['Read'][];
}
);
private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null;
protected async _createCookieHelper(): Promise<CookieHelper> {
@ -785,6 +794,20 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
},
};
}
protected _clientNotificationCategoryFromCrud(crud: NotificationPreferenceCrud['Client']['Read'], session: InternalSession): NotificationCategory {
const app = this;
return {
id: crud.notification_category_id,
name: crud.notification_category_name,
enabled: crud.enabled,
canDisable: crud.can_disable,
async setEnabled(enabled: boolean) {
await app._interface.setNotificationsEnabled(crud.notification_category_id, enabled, session);
await app._notificationCategoriesCache.refresh([session]);
},
};
}
protected _createAuth(session: InternalSession): Auth {
const app = this;
return {
@ -1077,7 +1100,16 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
await app._clientContactChannelsCache.refresh([session]);
return app._clientContactChannelFromCrud(crud, session);
},
// IF_PLATFORM react-like
useNotificationCategories() {
const results = useAsyncCache(app._notificationCategoriesCache, [session] as const, "user.useNotificationCategories()");
return results.map((crud) => app._clientNotificationCategoryFromCrud(crud, session));
},
// END_PLATFORM
async listNotificationCategories() {
const results = Result.orThrow(await app._notificationCategoriesCache.getOrWait([session], "write-only"));
return results.map((crud) => app._clientNotificationCategoryFromCrud(crud, session));
},
// IF_PLATFORM react-like
useApiKeys() {
const result = useAsyncCache(app._userApiKeysCache, [session] as const, "user.useApiKeys()");
@ -1099,6 +1131,8 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
await app._userApiKeysCache.refresh([session]);
return app._clientApiKeyFromCrud(session, result);
},
};
}

View File

@ -1,5 +1,6 @@
import { KnownErrors, StackServerInterface } from "@stackframe/stack-shared";
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys";
import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
@ -20,6 +21,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { NotificationCategory } from "../../notification-categories";
import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions";
import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams";
import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users";
@ -118,6 +120,11 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
return await this._interface.listServerContactChannels(userId);
}
);
private readonly _serverNotificationCategoriesCache = createCache<[string], NotificationPreferenceCrud['Server']['Read'][]>(
async ([userId]) => {
return await this._interface.listServerNotificationCategories(userId);
}
);
private readonly _serverUserApiKeysCache = createCache<[string], UserApiKeysCrud['Server']['Read'][]>(
async ([userId]) => {
@ -201,6 +208,21 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
};
}
protected _serverNotificationCategoryFromCrud(userId: string, crud: NotificationPreferenceCrud['Server']['Read']): NotificationCategory {
const app = this;
return {
id: crud.notification_category_id,
name: crud.notification_category_name,
enabled: crud.enabled,
canDisable: crud.can_disable,
async setEnabled(enabled: boolean) {
await app._interface.setServerNotificationsEnabled(userId, crud.notification_category_id, enabled);
await app._serverNotificationCategoriesCache.refresh([userId]);
},
};
}
constructor(options:
| StackServerAppConstructorOptions<HasTokenStore, ProjectId>
| {
@ -504,6 +526,16 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
return app._serverContactChannelFromCrud(crud.id, contactChannel);
},
// IF_PLATFORM react-like
useNotificationCategories() {
const results = useAsyncCache(app._serverNotificationCategoriesCache, [crud.id] as const, "user.useNotificationCategories()");
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
},
// END_PLATFORM
async listNotificationCategories() {
const results = Result.orThrow(await app._serverNotificationCategoriesCache.getOrWait([crud.id], "write-only"));
return results.map((category) => app._serverNotificationCategoryFromCrud(crud.id, category));
},
// IF_PLATFORM react-like
useApiKeys() {
const result = useAsyncCache(app._serverUserApiKeysCache, [crud.id] as const, "user.useApiKeys()");
return result.map((apiKey) => app._serverApiKeyFromCrud(apiKey));

View File

@ -0,0 +1,8 @@
export type NotificationCategory = {
id: string,
name: string,
enabled: boolean,
canDisable: boolean,
setEnabled(enabled: boolean): Promise<void>,
}

View File

@ -14,6 +14,7 @@ import { ContactChannel, ContactChannelCreateOptions, ServerContactChannel, Serv
import { AdminTeamPermission, TeamPermission } from "../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions } from "../projects";
import { EditableTeamMemberProfile, ServerTeam, ServerTeamCreateOptions, Team, TeamCreateOptions } from "../teams";
import { NotificationCategory } from "../notification-categories";
export type Session = {
@ -178,6 +179,9 @@ export type UserExtra = {
listContactChannels(): Promise<ContactChannel[]>,
createContactChannel(data: ContactChannelCreateOptions): Promise<ContactChannel>,
useNotificationCategories(): NotificationCategory[], // THIS_LINE_PLATFORM react-like
listNotificationCategories(): Promise<NotificationCategory[]>,
delete(): Promise<void>,
getConnectedAccount(id: ProviderType, options: { or: 'redirect', scopes?: string[] }): Promise<OAuthConnection>,