aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-10 08:35:32 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-10 08:37:44 +0000
commit93049e864ae6d281b60c23dee868bca3f585dd4a (patch)
treed39c0b4221486dbc82461a505f205d162a9e4def /packages
parentaae3ef17eccf0752edb5ce5638a58444ccb6ce3a (diff)
downloadkarakeep-93049e864ae6d281b60c23dee868bca3f585dd4a.tar.zst
feat: Add support for email verification
Diffstat (limited to 'packages')
-rw-r--r--packages/api/index.ts14
-rw-r--r--packages/shared/config.ts268
-rw-r--r--packages/shared/package.json2
-rw-r--r--packages/trpc/email.ts110
-rw-r--r--packages/trpc/package.json2
-rw-r--r--packages/trpc/routers/users.ts90
6 files changed, 362 insertions, 124 deletions
diff --git a/packages/api/index.ts b/packages/api/index.ts
index a2882381..82beca53 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -1,6 +1,6 @@
import { Hono } from "hono";
-import { logger } from "hono/logger";
import { cors } from "hono/cors";
+import { logger } from "hono/logger";
import { poweredBy } from "hono/powered-by";
import { Context } from "@karakeep/trpc";
@@ -39,11 +39,13 @@ const app = new Hono<{
}>()
.use(logger())
.use(poweredBy())
- .use(cors({
- origin: "*",
- allowHeaders: ["Authorization", "Content-Type"],
- credentials: true,
- }))
+ .use(
+ cors({
+ origin: "*",
+ allowHeaders: ["Authorization", "Content-Type"],
+ credentials: true,
+ }),
+ )
.use("*", registerMetrics)
.use(async (c, next) => {
// Ensure that the ctx is set
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index c435d012..5a6a3dad 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -97,6 +97,15 @@ const allEnv = z.object({
// Prometheus metrics configuration
PROMETHEUS_AUTH_TOKEN: z.string().optional(),
+ // Email configuration
+ SMTP_HOST: z.string().optional(),
+ SMTP_PORT: z.coerce.number().optional().default(587),
+ SMTP_SECURE: stringBool("false"),
+ SMTP_USER: z.string().optional(),
+ SMTP_PASSWORD: z.string().optional(),
+ SMTP_FROM: z.string().optional(),
+ EMAIL_VERIFICATION_REQUIRED: stringBool("false"),
+
// Asset storage configuration
ASSET_STORE_S3_ENDPOINT: z.string().optional(),
ASSET_STORE_S3_REGION: z.string().optional(),
@@ -106,130 +115,155 @@ const allEnv = z.object({
ASSET_STORE_S3_FORCE_PATH_STYLE: stringBool("false"),
});
-const serverConfigSchema = allEnv.transform((val) => {
- return {
- apiUrl: val.API_URL,
- publicUrl: val.NEXTAUTH_URL,
- publicApiUrl: `${val.NEXTAUTH_URL}/api`,
- signingSecret: () => {
- if (!val.NEXTAUTH_SECRET) {
- throw new Error("NEXTAUTH_SECRET is not set");
- }
- return val.NEXTAUTH_SECRET;
- },
- auth: {
- disableSignups: val.DISABLE_SIGNUPS,
- disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
- oauth: {
- allowDangerousEmailAccountLinking:
- val.OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING,
- wellKnownUrl: val.OAUTH_WELLKNOWN_URL,
- clientSecret: val.OAUTH_CLIENT_SECRET,
- clientId: val.OAUTH_CLIENT_ID,
- scope: val.OAUTH_SCOPE,
- name: val.OAUTH_PROVIDER_NAME,
- timeout: val.OAUTH_TIMEOUT,
+const serverConfigSchema = allEnv
+ .transform((val) => {
+ return {
+ apiUrl: val.API_URL,
+ publicUrl: val.NEXTAUTH_URL,
+ publicApiUrl: `${val.NEXTAUTH_URL}/api`,
+ signingSecret: () => {
+ if (!val.NEXTAUTH_SECRET) {
+ throw new Error("NEXTAUTH_SECRET is not set");
+ }
+ return val.NEXTAUTH_SECRET;
},
- },
- inference: {
- numWorkers: val.INFERENCE_NUM_WORKERS,
- jobTimeoutSec: val.INFERENCE_JOB_TIMEOUT_SEC,
- fetchTimeoutSec: val.INFERENCE_FETCH_TIMEOUT_SEC,
- openAIApiKey: val.OPENAI_API_KEY,
- openAIBaseUrl: val.OPENAI_BASE_URL,
- ollamaBaseUrl: val.OLLAMA_BASE_URL,
- ollamaKeepAlive: val.OLLAMA_KEEP_ALIVE,
- textModel: val.INFERENCE_TEXT_MODEL,
- imageModel: val.INFERENCE_IMAGE_MODEL,
- inferredTagLang: val.INFERENCE_LANG,
- contextLength: val.INFERENCE_CONTEXT_LENGTH,
- outputSchema:
- val.INFERENCE_SUPPORTS_STRUCTURED_OUTPUT !== undefined
- ? val.INFERENCE_SUPPORTS_STRUCTURED_OUTPUT
- ? ("structured" as const)
- : ("plain" as const)
- : val.INFERENCE_OUTPUT_SCHEMA,
- enableAutoTagging: val.INFERENCE_ENABLE_AUTO_TAGGING,
- enableAutoSummarization: val.INFERENCE_ENABLE_AUTO_SUMMARIZATION,
- },
- embedding: {
- textModel: val.EMBEDDING_TEXT_MODEL,
- },
- crawler: {
- numWorkers: val.CRAWLER_NUM_WORKERS,
- headlessBrowser: val.CRAWLER_HEADLESS_BROWSER,
- browserWebUrl: val.BROWSER_WEB_URL,
- browserWebSocketUrl: val.BROWSER_WEBSOCKET_URL,
- browserConnectOnDemand: val.BROWSER_CONNECT_ONDEMAND,
- jobTimeoutSec: val.CRAWLER_JOB_TIMEOUT_SEC,
- navigateTimeoutSec: val.CRAWLER_NAVIGATE_TIMEOUT_SEC,
- downloadBannerImage: val.CRAWLER_DOWNLOAD_BANNER_IMAGE,
- storeScreenshot: val.CRAWLER_STORE_SCREENSHOT,
- fullPageScreenshot: val.CRAWLER_FULL_PAGE_SCREENSHOT,
- fullPageArchive: val.CRAWLER_FULL_PAGE_ARCHIVE,
- downloadVideo: val.CRAWLER_VIDEO_DOWNLOAD,
- maxVideoDownloadSize: val.CRAWLER_VIDEO_DOWNLOAD_MAX_SIZE,
- downloadVideoTimeout: val.CRAWLER_VIDEO_DOWNLOAD_TIMEOUT_SEC,
- enableAdblocker: val.CRAWLER_ENABLE_ADBLOCKER,
- ytDlpArguments: val.CRAWLER_YTDLP_ARGS,
- screenshotTimeoutSec: val.CRAWLER_SCREENSHOT_TIMEOUT_SEC,
- },
- ocr: {
- langs: val.OCR_LANGS,
- cacheDir: val.OCR_CACHE_DIR,
- confidenceThreshold: val.OCR_CONFIDENCE_THRESHOLD,
- },
- search: {
- numWorkers: val.SEARCH_NUM_WORKERS,
- meilisearch: val.MEILI_ADDR
+ auth: {
+ disableSignups: val.DISABLE_SIGNUPS,
+ disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
+ emailVerificationRequired: val.EMAIL_VERIFICATION_REQUIRED,
+ oauth: {
+ allowDangerousEmailAccountLinking:
+ val.OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING,
+ wellKnownUrl: val.OAUTH_WELLKNOWN_URL,
+ clientSecret: val.OAUTH_CLIENT_SECRET,
+ clientId: val.OAUTH_CLIENT_ID,
+ scope: val.OAUTH_SCOPE,
+ name: val.OAUTH_PROVIDER_NAME,
+ timeout: val.OAUTH_TIMEOUT,
+ },
+ },
+ email: {
+ smtp: val.SMTP_HOST
+ ? {
+ host: val.SMTP_HOST,
+ port: val.SMTP_PORT,
+ secure: val.SMTP_SECURE,
+ user: val.SMTP_USER,
+ password: val.SMTP_PASSWORD,
+ from: val.SMTP_FROM,
+ }
+ : undefined,
+ },
+ inference: {
+ numWorkers: val.INFERENCE_NUM_WORKERS,
+ jobTimeoutSec: val.INFERENCE_JOB_TIMEOUT_SEC,
+ fetchTimeoutSec: val.INFERENCE_FETCH_TIMEOUT_SEC,
+ openAIApiKey: val.OPENAI_API_KEY,
+ openAIBaseUrl: val.OPENAI_BASE_URL,
+ ollamaBaseUrl: val.OLLAMA_BASE_URL,
+ ollamaKeepAlive: val.OLLAMA_KEEP_ALIVE,
+ textModel: val.INFERENCE_TEXT_MODEL,
+ imageModel: val.INFERENCE_IMAGE_MODEL,
+ inferredTagLang: val.INFERENCE_LANG,
+ contextLength: val.INFERENCE_CONTEXT_LENGTH,
+ outputSchema:
+ val.INFERENCE_SUPPORTS_STRUCTURED_OUTPUT !== undefined
+ ? val.INFERENCE_SUPPORTS_STRUCTURED_OUTPUT
+ ? ("structured" as const)
+ : ("plain" as const)
+ : val.INFERENCE_OUTPUT_SCHEMA,
+ enableAutoTagging: val.INFERENCE_ENABLE_AUTO_TAGGING,
+ enableAutoSummarization: val.INFERENCE_ENABLE_AUTO_SUMMARIZATION,
+ },
+ embedding: {
+ textModel: val.EMBEDDING_TEXT_MODEL,
+ },
+ crawler: {
+ numWorkers: val.CRAWLER_NUM_WORKERS,
+ headlessBrowser: val.CRAWLER_HEADLESS_BROWSER,
+ browserWebUrl: val.BROWSER_WEB_URL,
+ browserWebSocketUrl: val.BROWSER_WEBSOCKET_URL,
+ browserConnectOnDemand: val.BROWSER_CONNECT_ONDEMAND,
+ jobTimeoutSec: val.CRAWLER_JOB_TIMEOUT_SEC,
+ navigateTimeoutSec: val.CRAWLER_NAVIGATE_TIMEOUT_SEC,
+ downloadBannerImage: val.CRAWLER_DOWNLOAD_BANNER_IMAGE,
+ storeScreenshot: val.CRAWLER_STORE_SCREENSHOT,
+ fullPageScreenshot: val.CRAWLER_FULL_PAGE_SCREENSHOT,
+ fullPageArchive: val.CRAWLER_FULL_PAGE_ARCHIVE,
+ downloadVideo: val.CRAWLER_VIDEO_DOWNLOAD,
+ maxVideoDownloadSize: val.CRAWLER_VIDEO_DOWNLOAD_MAX_SIZE,
+ downloadVideoTimeout: val.CRAWLER_VIDEO_DOWNLOAD_TIMEOUT_SEC,
+ enableAdblocker: val.CRAWLER_ENABLE_ADBLOCKER,
+ ytDlpArguments: val.CRAWLER_YTDLP_ARGS,
+ screenshotTimeoutSec: val.CRAWLER_SCREENSHOT_TIMEOUT_SEC,
+ },
+ ocr: {
+ langs: val.OCR_LANGS,
+ cacheDir: val.OCR_CACHE_DIR,
+ confidenceThreshold: val.OCR_CONFIDENCE_THRESHOLD,
+ },
+ search: {
+ numWorkers: val.SEARCH_NUM_WORKERS,
+ meilisearch: val.MEILI_ADDR
+ ? {
+ address: val.MEILI_ADDR,
+ key: val.MEILI_MASTER_KEY,
+ }
+ : undefined,
+ },
+ logLevel: val.LOG_LEVEL,
+ demoMode: val.DEMO_MODE
? {
- address: val.MEILI_ADDR,
- key: val.MEILI_MASTER_KEY,
+ email: val.DEMO_MODE_EMAIL,
+ password: val.DEMO_MODE_PASSWORD,
}
: undefined,
- },
- logLevel: val.LOG_LEVEL,
- demoMode: val.DEMO_MODE
- ? {
- email: val.DEMO_MODE_EMAIL,
- password: val.DEMO_MODE_PASSWORD,
- }
- : undefined,
- dataDir: val.DATA_DIR,
- assetsDir: val.ASSETS_DIR ?? path.join(val.DATA_DIR, "assets"),
- maxAssetSizeMb: val.MAX_ASSET_SIZE_MB,
- serverVersion: val.SERVER_VERSION,
- disableNewReleaseCheck: val.DISABLE_NEW_RELEASE_CHECK,
- usingLegacySeparateContainers: val.USING_LEGACY_SEPARATE_CONTAINERS,
- webhook: {
- timeoutSec: val.WEBHOOK_TIMEOUT_SEC,
- retryTimes: val.WEBHOOK_RETRY_TIMES,
- numWorkers: val.WEBHOOK_NUM_WORKERS,
- },
- assetPreprocessing: {
- numWorkers: val.ASSET_PREPROCESSING_NUM_WORKERS,
- },
- ruleEngine: {
- numWorkers: val.RULE_ENGINE_NUM_WORKERS,
- },
- assetStore: {
- type: val.ASSET_STORE_S3_ENDPOINT
- ? ("s3" as const)
- : ("filesystem" as const),
- s3: {
- endpoint: val.ASSET_STORE_S3_ENDPOINT,
- region: val.ASSET_STORE_S3_REGION,
- bucket: val.ASSET_STORE_S3_BUCKET,
- accessKeyId: val.ASSET_STORE_S3_ACCESS_KEY_ID,
- secretAccessKey: val.ASSET_STORE_S3_SECRET_ACCESS_KEY,
- forcePathStyle: val.ASSET_STORE_S3_FORCE_PATH_STYLE,
+ dataDir: val.DATA_DIR,
+ assetsDir: val.ASSETS_DIR ?? path.join(val.DATA_DIR, "assets"),
+ maxAssetSizeMb: val.MAX_ASSET_SIZE_MB,
+ serverVersion: val.SERVER_VERSION,
+ disableNewReleaseCheck: val.DISABLE_NEW_RELEASE_CHECK,
+ usingLegacySeparateContainers: val.USING_LEGACY_SEPARATE_CONTAINERS,
+ webhook: {
+ timeoutSec: val.WEBHOOK_TIMEOUT_SEC,
+ retryTimes: val.WEBHOOK_RETRY_TIMES,
+ numWorkers: val.WEBHOOK_NUM_WORKERS,
},
+ assetPreprocessing: {
+ numWorkers: val.ASSET_PREPROCESSING_NUM_WORKERS,
+ },
+ ruleEngine: {
+ numWorkers: val.RULE_ENGINE_NUM_WORKERS,
+ },
+ assetStore: {
+ type: val.ASSET_STORE_S3_ENDPOINT
+ ? ("s3" as const)
+ : ("filesystem" as const),
+ s3: {
+ endpoint: val.ASSET_STORE_S3_ENDPOINT,
+ region: val.ASSET_STORE_S3_REGION,
+ bucket: val.ASSET_STORE_S3_BUCKET,
+ accessKeyId: val.ASSET_STORE_S3_ACCESS_KEY_ID,
+ secretAccessKey: val.ASSET_STORE_S3_SECRET_ACCESS_KEY,
+ forcePathStyle: val.ASSET_STORE_S3_FORCE_PATH_STYLE,
+ },
+ },
+ prometheus: {
+ metricsToken: val.PROMETHEUS_AUTH_TOKEN,
+ },
+ };
+ })
+ .refine(
+ (val) => {
+ if (val.auth.emailVerificationRequired && !val.email.smtp) {
+ return false;
+ }
+ return true;
},
- prometheus: {
- metricsToken: val.PROMETHEUS_AUTH_TOKEN,
+ {
+ message: "To enable email verification, SMTP settings must be configured",
},
- };
-});
+ );
const serverConfig = serverConfigSchema.parse(process.env);
// Always explicitly pick up stuff from server config to avoid accidentally leaking stuff
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 6f22865f..0210e24f 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -11,6 +11,7 @@
"js-tiktoken": "^1.0.20",
"liteque": "^0.3.2",
"meilisearch": "^0.37.0",
+ "nodemailer": "^7.0.4",
"ollama": "^0.5.14",
"openai": "^4.86.1",
"typescript-parsec": "^0.3.4",
@@ -22,6 +23,7 @@
"@karakeep/prettier-config": "workspace:^0.1.0",
"@karakeep/tsconfig": "workspace:^0.1.0",
"@types/html-to-text": "^9.0.4",
+ "@types/nodemailer": "^6.4.17",
"vitest": "^1.6.1"
},
"scripts": {
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
new file mode 100644
index 00000000..2ca3e396
--- /dev/null
+++ b/packages/trpc/email.ts
@@ -0,0 +1,110 @@
+import { randomBytes } from "crypto";
+import { and, eq } from "drizzle-orm";
+import { createTransport } from "nodemailer";
+
+import { db } from "@karakeep/db";
+import { verificationTokens } from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
+
+export async function sendVerificationEmail(email: string, name: string) {
+ if (!serverConfig.email.smtp) {
+ throw new Error("SMTP is not configured");
+ }
+
+ const token = randomBytes(10).toString("hex");
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
+
+ // Store verification token
+ await db.insert(verificationTokens).values({
+ identifier: email,
+ token,
+ expires,
+ });
+
+ const transporter = createTransport({
+ host: serverConfig.email.smtp.host,
+ port: serverConfig.email.smtp.port,
+ secure: serverConfig.email.smtp.secure,
+ auth:
+ serverConfig.email.smtp.user && serverConfig.email.smtp.password
+ ? {
+ user: serverConfig.email.smtp.user,
+ pass: serverConfig.email.smtp.password,
+ }
+ : undefined,
+ });
+
+ const verificationUrl = `${serverConfig.publicUrl}/verify-email?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: "Verify your email address",
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>Welcome to Karakeep, ${name}!</h2>
+ <p>Please verify your email address by clicking the link below:</p>
+ <p>
+ <a href="${verificationUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ Verify Email Address
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${verificationUrl}">${verificationUrl}</a></p>
+ <p>This link will expire in 24 hours.</p>
+ <p>If you didn't create an account with us, please ignore this email.</p>
+ </div>
+ `,
+ text: `
+Welcome to Karakeep, ${name}!
+
+Please verify your email address by visiting this link:
+${verificationUrl}
+
+This link will expire in 24 hours.
+
+If you didn't create an account with us, please ignore this email.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+}
+
+export async function verifyEmailToken(
+ email: string,
+ token: string,
+): Promise<boolean> {
+ const verificationToken = await db.query.verificationTokens.findFirst({
+ where: (vt, { and, eq }) =>
+ and(eq(vt.identifier, email), eq(vt.token, token)),
+ });
+
+ if (!verificationToken) {
+ return false;
+ }
+
+ if (verificationToken.expires < new Date()) {
+ // Clean up expired token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+ return false;
+ }
+
+ // Clean up used token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+
+ return true;
+}
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index f4a9d122..43792d9a 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -20,6 +20,7 @@
"deep-equal": "^2.2.3",
"drizzle-orm": "^0.38.3",
"prom-client": "^15.1.3",
+ "nodemailer": "^7.0.4",
"superjson": "^2.2.1",
"tiny-invariant": "^1.3.3",
"zod": "^3.24.2"
@@ -29,6 +30,7 @@
"@karakeep/tsconfig": "workspace:^0.1.0",
"@types/bcryptjs": "^2.4.6",
"@types/deep-equal": "^1.0.4",
+ "@types/nodemailer": "^6.4.17",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.6.1"
},
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index 17c9fa3a..58093b42 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -26,6 +26,7 @@ import {
} from "@karakeep/shared/types/users";
import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
+import { sendVerificationEmail, verifyEmailToken } from "../email";
import {
adminProcedure,
authedProcedure,
@@ -102,13 +103,23 @@ export async function createUser(
role?: "user" | "admin",
) {
const salt = generatePasswordSalt();
- return await createUserRaw(ctx.db, {
+ let user = await createUserRaw(ctx.db, {
name: input.name,
email: input.email,
password: await hashPassword(input.password, salt),
salt,
role,
});
+ // Send verification email if required
+ if (serverConfig.auth.emailVerificationRequired) {
+ try {
+ await sendVerificationEmail(input.email, input.name);
+ } catch (error) {
+ console.error("Failed to send verification email:", error);
+ // Don't fail user creation if email sending fails
+ }
+ }
+ return user;
}
export const usersAppRouter = router({
@@ -529,4 +540,81 @@ export const usersAppRouter = router({
})
.where(eq(userSettings.userId, ctx.user.id));
}),
+ verifyEmail: publicProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ token: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const isValid = await verifyEmailToken(input.email, input.token);
+ if (!isValid) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired verification token",
+ });
+ }
+
+ // Update user's emailVerified status
+ const result = await ctx.db
+ .update(users)
+ .set({ emailVerified: new Date() })
+ .where(eq(users.email, input.email));
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ return { success: true };
+ }),
+ resendVerificationEmail: publicProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ if (
+ !serverConfig.auth.emailVerificationRequired ||
+ !serverConfig.email.smtp
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email verification is not enabled",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ if (user.emailVerified) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email is already verified",
+ });
+ }
+
+ try {
+ await sendVerificationEmail(input.email, user.name);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to send verification email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send verification email",
+ });
+ }
+ }),
});