diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 08:35:32 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-10 08:37:44 +0000 |
| commit | 93049e864ae6d281b60c23dee868bca3f585dd4a (patch) | |
| tree | d39c0b4221486dbc82461a505f205d162a9e4def /packages | |
| parent | aae3ef17eccf0752edb5ce5638a58444ccb6ce3a (diff) | |
| download | karakeep-93049e864ae6d281b60c23dee868bca3f585dd4a.tar.zst | |
feat: Add support for email verification
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/index.ts | 14 | ||||
| -rw-r--r-- | packages/shared/config.ts | 268 | ||||
| -rw-r--r-- | packages/shared/package.json | 2 | ||||
| -rw-r--r-- | packages/trpc/email.ts | 110 | ||||
| -rw-r--r-- | packages/trpc/package.json | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 90 |
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", + }); + } + }), }); |
