diff --git a/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql b/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql
new file mode 100644
index 000000000..9b96ac9c0
--- /dev/null
+++ b/apps/backend/prisma/migrations/20250619200740_user_notification_pref/migration.sql
@@ -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;
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
index 234be5613..55d7be665 100644
--- a/apps/backend/prisma/schema.prisma
+++ b/apps/backend/prisma/schema.prisma
@@ -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])
+}
diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx
new file mode 100644
index 000000000..4866618aa
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/[notification_category_id]/route.tsx
@@ -0,0 +1,3 @@
+import { notificationPreferencesCrudHandlers } from "../../crud";
+
+export const PATCH = notificationPreferencesCrudHandlers.updateHandler;
diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx
new file mode 100644
index 000000000..103180136
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/notification-preference/[user_id]/route.tsx
@@ -0,0 +1,3 @@
+import { notificationPreferencesCrudHandlers } from "../crud";
+
+export const GET = notificationPreferencesCrudHandlers.listHandler;
diff --git a/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx b/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx
new file mode 100644
index 000000000..7a9078fff
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/notification-preference/crud.tsx
@@ -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,
+ };
+ },
+}));
diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx
new file mode 100644
index 000000000..40d1dfdd2
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx
@@ -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 += `
Click here to unsubscribe`;
+ }
+
+ 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,
+ },
+ };
+ },
+});
diff --git a/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx
new file mode 100644
index 000000000..a084b4cb8
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx
@@ -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('
You have already unsubscribed from this notification group
', {
+ 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('Successfully unsubscribed from notification group
', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ });
+}
diff --git a/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx b/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx
new file mode 100644
index 000000000..6910921e2
--- /dev/null
+++ b/apps/backend/src/app/api/latest/emails/unsubscribe-link/verification-handler.tsx
@@ -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;
+ },
+});
diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx
index 4df4c3457..09ebe6491 100644
--- a/apps/backend/src/lib/emails.tsx
+++ b/apps/backend/src/lib/emails.tsx
@@ -332,7 +332,7 @@ export async function sendEmailFromTemplate(options: {
});
}
-async function getEmailConfig(tenancy: Tenancy): Promise {
+export async function getEmailConfig(tenancy: Tenancy): Promise {
const projectEmailConfig = tenancy.config.email_config;
if (projectEmailConfig.type === 'shared') {
diff --git a/apps/backend/src/lib/notification-categories.ts b/apps/backend/src/lib/notification-categories.ts
new file mode 100644
index 000000000..c7a9acccb
--- /dev/null
+++ b/apps/backend/src/lib/notification-categories.ts
@@ -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}¬ification_category_id=${notificationCategoryId}`;
+};
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts
new file mode 100644
index 000000000..6766a86be
--- /dev/null
+++ b/apps/e2e/tests/backend/endpoints/api/v1/notification-preferences.test.ts
@@ -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",
+ ,
+ },
+ }
+ `);
+ });
+
+ 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 { },
+ }
+ `);
+ });
+});
+
+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": "",
+ "notification_category_name": "Transactional",
+ },
+ {
+ "can_disable": true,
+ "enabled": true,
+ "notification_category_id": "",
+ "notification_category_name": "Marketing",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
+});
+
+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": "",
+ "notification_category_name": "Marketing",
+ },
+ "headers": Headers { },
+ }
+ `);
+
+ 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": "",
+ "notification_category_name": "Transactional",
+ },
+ {
+ "can_disable": true,
+ "enabled": false,
+ "notification_category_id": "",
+ "notification_category_name": "Marketing",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
+});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
new file mode 100644
index 000000000..2ae934cc8
--- /dev/null
+++ b/apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts
@@ -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: "Test email
",
+ 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",
+ ,
+ },
+ }
+ `);
+ });
+
+ 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: "Test email
",
+ subject: "Test Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 404,
+ "body": "User not found",
+ "headers": Headers { },
+ }
+ `);
+ });
+
+ 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: "Test email
",
+ 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 { },
+ }
+ `);
+ });
+
+ 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: "Test email
",
+ subject: "Test Subject",
+ notification_category_name: "Invalid",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 404,
+ "body": "Notification category not found",
+ "headers": Headers { },
+ }
+ `);
+ });
+});
+
+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": "",
+ "notification_category_name": "Marketing",
+ },
+ "headers": Headers { },
+ }
+ `);
+
+ const response = await niceBackendFetch(
+ "/api/v1/emails/send-email",
+ {
+ method: "POST",
+ accessType: "server",
+ body: {
+ user_id: user.userId,
+ html: "Test email
",
+ subject: "Test Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 400,
+ "body": "User has disabled notifications for this category",
+ "headers": Headers { },
+ }
+ `);
+});
+
+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: "Test email
",
+ subject: "Test Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 400,
+ "body": "User does not have a primary email",
+ "headers": Headers { },
+ }
+ `);
+});
+
+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: "Test Email
This is a test email with HTML content.
",
+ subject: "Custom Test Email Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": { "user_email": "unindexed-mailbox--@stack-generated.example.com" },
+ "headers": Headers { },
+ }
+ `);
+
+ // 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("Test Email
");
+ expect(sentEmail!.body?.html).toContain("This is a test email with HTML content.
");
+});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
new file mode 100644
index 000000000..4384e4f27
--- /dev/null
+++ b/apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts
@@ -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: "Test Email
This is a test email with HTML content.
",
+ subject: "Custom Test Email Subject",
+ notification_category_name: "Marketing",
+ }
+ }
+ );
+
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": { "user_email": "unindexed-mailbox--@stack-generated.example.com" },
+ "headers": Headers { },
+ }
+ `);
+
+ // 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(/Test Email<\/h1>
This is a test email with HTML content\.<\/p>
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("Successfully unsubscribed from notification group
");
+
+ 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": "",
+ "notification_category_name": "Transactional",
+ },
+ {
+ "can_disable": true,
+ "enabled": false,
+ "notification_category_id": "",
+ "notification_category_name": "Marketing",
+ },
+ ],
+ },
+ "headers": Headers { },
+ }
+ `);
+});
+
+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: "Test Email
This is a test email with HTML content.
",
+ subject: "Custom Test Email Subject",
+ notification_category_name: "Transactional",
+ }
+ }
+ );
+
+ expect(response).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": { "user_email": "unindexed-mailbox--@stack-generated.example.com" },
+ "headers": Headers { },
+ }
+ `);
+
+ 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(`"Test Email
This is a test email with HTML content.
\\n"`);
+});
diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts
index 02b8b43dc..ec5880055 100644
--- a/packages/stack-shared/src/interface/client-interface.ts
+++ b/packages/stack-shared/src/interface/client-interface.ts
@@ -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 {
+ 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 {
+ await this.sendClientRequest(
+ `/emails/notification-preference/me/${notificationCategoryId}`,
+ {
+ method: "PATCH",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ enabled,
+ }),
+ },
+ session,
+ );
+ }
}
diff --git a/packages/stack-shared/src/interface/crud/notification-preferences.ts b/packages/stack-shared/src/interface/crud/notification-preferences.ts
new file mode 100644
index 000000000..004bd1576
--- /dev/null
+++ b/packages/stack-shared/src/interface/crud/notification-preferences.ts
@@ -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;
diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts
index f90dbcb44..69ea77f95 100644
--- a/packages/stack-shared/src/interface/server-interface.ts
+++ b/packages/stack-shared/src/interface/server-interface.ts
@@ -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 {
+ 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 {
+ 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,
diff --git a/packages/template/src/components-page/account-settings.tsx b/packages/template/src/components-page/account-settings.tsx
index ca00e4f51..02f3258f8 100644
--- a/packages/template/src/components-page/account-settings.tsx
+++ b/packages/template/src/components-page/account-settings.tsx
@@ -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: {
,
},
+ {
+ title: t('Notifications'),
+ type: 'item',
+ id: 'notifications',
+ icon: ,
+ content: }>
+
+ ,
+ },
{
title: t('Active Sessions'),
type: 'item',
@@ -219,3 +229,10 @@ function TeamCreationSkeleton() {
;
}
+
+function NotificationsPageSkeleton() {
+ return
+
+
+ ;
+}
diff --git a/packages/template/src/components-page/account-settings/notifications/notifications-page.tsx b/packages/template/src/components-page/account-settings/notifications/notifications-page.tsx
new file mode 100644
index 000000000..5b39b7dcd
--- /dev/null
+++ b/packages/template/src/components-page/account-settings/notifications/notifications-page.tsx
@@ -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 (
+
+
+
+
+
+ {t('Choose which emails you want to receive')}
+
+
+ {notificationCategories.map((category) => (
+
+ void category.setEnabled(value)}
+ disabled={!category.canDisable}
+ />
+ {category.name}
+ {!category.canDisable && (
+
+ (cannot be disabled)
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
index c49224295..b6f83e32a 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
@@ -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(
+ 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 {
@@ -785,6 +794,20 @@ export class _StackClientAppImplIncomplete 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(
+ 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
| {
@@ -504,6 +526,16 @@ export class _StackServerAppImplIncomplete 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));
diff --git a/packages/template/src/lib/stack-app/notification-categories/index.ts b/packages/template/src/lib/stack-app/notification-categories/index.ts
new file mode 100644
index 000000000..b49a78324
--- /dev/null
+++ b/packages/template/src/lib/stack-app/notification-categories/index.ts
@@ -0,0 +1,8 @@
+export type NotificationCategory = {
+ id: string,
+ name: string,
+ enabled: boolean,
+ canDisable: boolean,
+
+ setEnabled(enabled: boolean): Promise,
+}
diff --git a/packages/template/src/lib/stack-app/users/index.ts b/packages/template/src/lib/stack-app/users/index.ts
index 4da5c27a6..84b86b63d 100644
--- a/packages/template/src/lib/stack-app/users/index.ts
+++ b/packages/template/src/lib/stack-app/users/index.ts
@@ -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,
createContactChannel(data: ContactChannelCreateOptions): Promise,
+ useNotificationCategories(): NotificationCategory[], // THIS_LINE_PLATFORM react-like
+ listNotificationCategories(): Promise,
+
delete(): Promise,
getConnectedAccount(id: ProviderType, options: { or: 'redirect', scopes?: string[] }): Promise,