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 | |
| parent | aae3ef17eccf0752edb5ce5638a58444ccb6ce3a (diff) | |
| download | karakeep-93049e864ae6d281b60c23dee868bca3f585dd4a.tar.zst | |
feat: Add support for email verification
| -rw-r--r-- | apps/web/app/check-email/page.tsx | 128 | ||||
| -rw-r--r-- | apps/web/app/verify-email/page.tsx | 152 | ||||
| -rw-r--r-- | apps/web/components/signin/CredentialsForm.tsx | 28 | ||||
| -rw-r--r-- | apps/web/server/auth.ts | 36 | ||||
| -rw-r--r-- | docs/docs/03-configuration.md | 50 | ||||
| -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 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 25 |
12 files changed, 748 insertions, 157 deletions
diff --git a/apps/web/app/check-email/page.tsx b/apps/web/app/check-email/page.tsx new file mode 100644 index 00000000..96f0afb4 --- /dev/null +++ b/apps/web/app/check-email/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/lib/trpc"; +import { Loader2, Mail } from "lucide-react"; + +export default function CheckEmailPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [message, setMessage] = useState(""); + + const email = searchParams.get("email"); + + const resendEmailMutation = api.users.resendVerificationEmail.useMutation({ + onSuccess: () => { + setMessage( + "A new verification email has been sent to your email address.", + ); + }, + onError: (error) => { + setMessage(error.message || "Failed to resend verification email."); + }, + }); + + const handleResendEmail = () => { + if (email) { + resendEmailMutation.mutate({ email }); + } + }; + + const handleBackToSignIn = () => { + router.push("/signin"); + }; + + if (!email) { + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Invalid Request + </CardTitle> + <CardDescription> + No email address provided. Please try signing up again. + </CardDescription> + </CardHeader> + <CardContent> + <Button onClick={handleBackToSignIn} className="w-full"> + Back to Sign In + </Button> + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold">Check Your Email</CardTitle> + <CardDescription> + We've sent a verification link to your email address + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-center"> + <Mail className="h-12 w-12 text-blue-600" /> + </div> + + <div className="space-y-2 text-center"> + <p className="text-sm text-gray-600"> + We've sent a verification email to: + </p> + <p className="font-medium text-gray-900">{email}</p> + <p className="text-sm text-gray-600"> + Click the link in the email to verify your account and complete + your registration. + </p> + </div> + + {message && ( + <Alert> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + )} + + <div className="space-y-2"> + <Button + onClick={handleResendEmail} + variant="outline" + className="w-full" + disabled={resendEmailMutation.isPending} + > + {resendEmailMutation.isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + "Resend Verification Email" + )} + </Button> + <Button + onClick={handleBackToSignIn} + variant="ghost" + className="w-full" + > + Back to Sign In + </Button> + </div> + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx new file mode 100644 index 00000000..e8792465 --- /dev/null +++ b/apps/web/app/verify-email/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/lib/trpc"; +import { CheckCircle, Loader2, XCircle } from "lucide-react"; + +export default function VerifyEmailPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading", + ); + const [message, setMessage] = useState(""); + + const token = searchParams.get("token"); + const email = searchParams.get("email"); + + const verifyEmailMutation = api.users.verifyEmail.useMutation({ + onSuccess: () => { + setStatus("success"); + setMessage( + "Your email has been successfully verified! You can now sign in.", + ); + }, + onError: (error) => { + setStatus("error"); + setMessage( + error.message || + "Failed to verify email. The link may be invalid or expired.", + ); + }, + }); + + const resendEmailMutation = api.users.resendVerificationEmail.useMutation({ + onSuccess: () => { + setMessage( + "A new verification email has been sent to your email address.", + ); + }, + onError: (error) => { + setMessage(error.message || "Failed to resend verification email."); + }, + }); + + useEffect(() => { + if (token && email) { + verifyEmailMutation.mutate({ token, email }); + } else { + setStatus("error"); + setMessage("Invalid verification link. Missing token or email."); + } + }, [token, email]); + + const handleResendEmail = () => { + if (email) { + resendEmailMutation.mutate({ email }); + } + }; + + const handleSignIn = () => { + router.push("/signin"); + }; + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + Email Verification + </CardTitle> + <CardDescription> + {status === "loading" && "Verifying your email address..."} + {status === "success" && "Email verified successfully!"} + {status === "error" && "Verification failed"} + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {status === "loading" && ( + <div className="flex items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-blue-600" /> + </div> + )} + + {status === "success" && ( + <> + <div className="flex items-center justify-center"> + <CheckCircle className="h-12 w-12 text-green-600" /> + </div> + <Alert> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + <Button onClick={handleSignIn} className="w-full"> + Sign In + </Button> + </> + )} + + {status === "error" && ( + <> + <div className="flex items-center justify-center"> + <XCircle className="h-12 w-12 text-red-600" /> + </div> + <Alert variant="destructive"> + <AlertDescription className="text-center"> + {message} + </AlertDescription> + </Alert> + {email && ( + <div className="space-y-2"> + <Button + onClick={handleResendEmail} + variant="outline" + className="w-full" + disabled={resendEmailMutation.isPending} + > + {resendEmailMutation.isPending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Sending... + </> + ) : ( + "Resend Verification Email" + )} + </Button> + <Button + onClick={handleSignIn} + variant="ghost" + className="w-full" + > + Back to Sign In + </Button> + </div> + )} + </> + )} + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 3772db09..05aa1cef 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -28,9 +28,11 @@ const signInSchema = z.object({ password: z.string(), }); -const SIGNIN_FAILED = "Incorrect username or password"; +const SIGNIN_FAILED = "Incorrect email or password"; const OAUTH_FAILED = "OAuth login failed: "; +const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; + function SignIn() { const [signinError, setSigninError] = useState(""); const router = useRouter(); @@ -68,8 +70,16 @@ function SignIn() { email: value.email.trim(), password: value.password, }); - if (!resp || !resp?.ok) { - setSigninError(SIGNIN_FAILED); + if (!resp || !resp?.ok || resp.error) { + if (resp?.error === "CredentialsSignin") { + setSigninError(SIGNIN_FAILED); + } else if (resp?.error === VERIFY_EMAIL_ERROR) { + router.replace( + `/check-email?email=${encodeURIComponent(value.email.trim())}`, + ); + } else { + setSigninError(resp?.error ?? SIGNIN_FAILED); + } return; } router.replace("/"); @@ -149,8 +159,16 @@ function SignUp() { email: value.email.trim(), password: value.password, }); - if (!resp || !resp.ok) { - setErrorMessage("Hit an unexpected error while signing in"); + if (!resp || !resp.ok || resp.error) { + if (resp?.error === VERIFY_EMAIL_ERROR) { + router.replace( + `/check-email?email=${encodeURIComponent(value.email.trim())}`, + ); + } else { + setErrorMessage( + resp?.error ?? "Hit an unexpected error while signing in", + ); + } return; } router.replace("/"); diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 3d32f702..e7b5e1cb 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -1,6 +1,6 @@ import { Adapter, AdapterUser } from "@auth/core/adapters"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; -import { and, count, eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import NextAuth, { DefaultSession, getServerSession, @@ -169,22 +169,38 @@ export const authOptions: NextAuthOptions = { newUser: "/signin", }, callbacks: { - async signIn({ credentials, profile }) { + async signIn({ user: credUser, credentials, profile }) { + const email = credUser.email || profile?.email; + if (!email) { + throw new Error("Provider didn't provide an email during signin"); + } + const user = await db.query.users.findFirst({ + columns: { emailVerified: true }, + where: eq(users.email, email), + }); + if (credentials) { + if (!user) { + throw new Error("Invalid credentials"); + } + if ( + serverConfig.auth.emailVerificationRequired && + !user.emailVerified + ) { + throw new Error("Please verify your email address before signing in"); + } return true; } - if (!profile?.email) { - throw new Error("No profile"); - } - const [{ count: userCount }] = await db - .select({ count: count() }) - .from(users) - .where(and(eq(users.email, profile.email))); // If it's a new user and signups are disabled, fail the sign in - if (userCount === 0 && serverConfig.auth.disableSignups) { + if (!user && serverConfig.auth.disableSignups) { throw new Error("Signups are disabled in server config"); } + + // TODO: We're blindly trusting oauth providers to validate emails + // As such, oauth users can sign in even if email verification is enabled. + // We might want to change this in the future. + return true; }, async jwt({ token, user }) { diff --git a/docs/docs/03-configuration.md b/docs/docs/03-configuration.md index c7b533ff..7fa212ed 100644 --- a/docs/docs/03-configuration.md +++ b/docs/docs/03-configuration.md @@ -48,17 +48,18 @@ Only OIDC compliant OAuth providers are supported! For information on how to set When setting up OAuth, the allowed redirect URLs configured at the provider should be set to `<KARAKEEP_ADDRESS>/api/auth/callback/custom` where `<KARAKEEP_ADDRESS>` is the address you configured in `NEXTAUTH_URL` (for example: `https://try.karakeep.app/api/auth/callback/custom`). ::: -| Name | Required | Default | Description | -| ------------------------------------------- | -------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| DISABLE_SIGNUPS | No | false | If enabled, no new signups will be allowed and the signup button will be disabled in the UI | -| DISABLE_PASSWORD_AUTH | No | false | If enabled, only signups and logins using OAuth are allowed and the signup button and login form for local accounts will be disabled in the UI | -| OAUTH_WELLKNOWN_URL | No | Not set | The "wellknown Url" for openid-configuration as provided by the OAuth provider | -| OAUTH_CLIENT_SECRET | No | Not set | The "Client Secret" as provided by the OAuth provider | -| OAUTH_CLIENT_ID | No | Not set | The "Client ID" as provided by the OAuth provider | -| OAUTH_SCOPE | No | "openid email profile" | "Full list of scopes to request (space delimited)" | -| OAUTH_PROVIDER_NAME | No | "Custom Provider" | The name of your provider. Will be shown on the signup page as "Sign in with `<name>`" | -| OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING | No | false | Whether existing accounts in karakeep stored in the database should automatically be linked with your OAuth account. Only enable it if you trust the OAuth provider! | -| OAUTH_TIMEOUT | No | 3500 | The wait time in milliseconds for the OAuth provider response. Increase this if you are having `outgoing request timed out` errors | +| Name | Required | Default | Description | +| ------------------------------------------- | -------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DISABLE_SIGNUPS | No | false | If enabled, no new signups will be allowed and the signup button will be disabled in the UI | +| DISABLE_PASSWORD_AUTH | No | false | If enabled, only signups and logins using OAuth are allowed and the signup button and login form for local accounts will be disabled in the UI | +| EMAIL_VERIFICATION_REQUIRED | No | false | Whether email verification is required during user signup. If enabled, users must verify their email address before they can use their account. If you enable this, you must configure SMTP settings. | +| OAUTH_WELLKNOWN_URL | No | Not set | The "wellknown Url" for openid-configuration as provided by the OAuth provider | +| OAUTH_CLIENT_SECRET | No | Not set | The "Client Secret" as provided by the OAuth provider | +| OAUTH_CLIENT_ID | No | Not set | The "Client ID" as provided by the OAuth provider | +| OAUTH_SCOPE | No | "openid email profile" | "Full list of scopes to request (space delimited)" | +| OAUTH_PROVIDER_NAME | No | "Custom Provider" | The name of your provider. Will be shown on the signup page as "Sign in with `<name>`" | +| OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING | No | false | Whether existing accounts in karakeep stored in the database should automatically be linked with your OAuth account. Only enable it if you trust the OAuth provider! | +| OAUTH_TIMEOUT | No | 3500 | The wait time in milliseconds for the OAuth provider response. Increase this if you are having `outgoing request timed out` errors | For more information on `OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING`, check the [next-auth.js documentation](https://next-auth.js.org/configuration/providers/oauth#allowdangerousemailaccountlinking-option). @@ -121,13 +122,13 @@ Either `OPENAI_API_KEY` or `OLLAMA_BASE_URL` need to be set for automatic taggin These settings control the number of concurrent workers for different background processing tasks. Increasing these values can improve throughput but will consume more system resources. -| Name | Required | Default | Description | -| ------------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INFERENCE_NUM_WORKERS | No | 1 | Number of concurrent workers for AI inference tasks (tagging and summarization). Increase this if you have multiple AI inference requests and want to process them in parallel. | -| SEARCH_NUM_WORKERS | No | 1 | Number of concurrent workers for search indexing tasks. Increase this if you have a high volume of content being indexed for search. | -| WEBHOOK_NUM_WORKERS | No | 1 | Number of concurrent workers for webhook delivery. Increase this if you have multiple webhook endpoints or high webhook traffic. | -| ASSET_PREPROCESSING_NUM_WORKERS | No | 1 | Number of concurrent workers for asset preprocessing tasks (image processing, OCR, etc.). Increase this if you have many images or documents that need processing. | -| RULE_ENGINE_NUM_WORKERS | No | 1 | Number of concurrent workers for rule engine processing. Increase this if you have complex automation rules that need to be processed quickly. | +| Name | Required | Default | Description | +| ------------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INFERENCE_NUM_WORKERS | No | 1 | Number of concurrent workers for AI inference tasks (tagging and summarization). Increase this if you have multiple AI inference requests and want to process them in parallel. | +| SEARCH_NUM_WORKERS | No | 1 | Number of concurrent workers for search indexing tasks. Increase this if you have a high volume of content being indexed for search. | +| WEBHOOK_NUM_WORKERS | No | 1 | Number of concurrent workers for webhook delivery. Increase this if you have multiple webhook endpoints or high webhook traffic. | +| ASSET_PREPROCESSING_NUM_WORKERS | No | 1 | Number of concurrent workers for asset preprocessing tasks (image processing, OCR, etc.). Increase this if you have many images or documents that need processing. | +| RULE_ENGINE_NUM_WORKERS | No | 1 | Number of concurrent workers for rule engine processing. Increase this if you have complex automation rules that need to be processed quickly. | ## OCR Configs @@ -168,3 +169,16 @@ You can use webhooks to trigger actions when bookmarks are created, changed or c ``` ::: + +## SMTP Configuration + +Karakeep can send emails for various purposes such as email verification during signup. Configure these settings to enable email functionality. + +| Name | Required | Default | Description | +| ------------- | -------- | ------- | ----------------------------------------------------------------------------------------------- | +| SMTP_HOST | No | Not set | The SMTP server hostname or IP address. Required if you want to enable email functionality. | +| SMTP_PORT | No | 587 | The SMTP server port. Common values are 587 (STARTTLS), 465 (SSL/TLS), or 25 (unencrypted). | +| SMTP_SECURE | No | false | Whether to use SSL/TLS encryption. Set to true for port 465, false for port 587 with STARTTLS. | +| SMTP_USER | No | Not set | The username for SMTP authentication. Usually your email address. | +| SMTP_PASSWORD | No | Not set | The password for SMTP authentication. For services like Gmail, use an app-specific password. | +| SMTP_FROM | No | Not set | The "from" email address that will appear in sent emails. This should be a valid email address. | 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", + }); + } + }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3649d5b7..75c46a1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1121,6 +1121,9 @@ importers: meilisearch: specifier: ^0.37.0 version: 0.37.0(encoding@0.1.13) + nodemailer: + specifier: ^7.0.4 + version: 7.0.4 ollama: specifier: ^0.5.14 version: 0.5.16 @@ -1149,6 +1152,9 @@ importers: '@types/html-to-text': specifier: ^9.0.4 version: 9.0.4 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 vitest: specifier: ^1.6.1 version: 1.6.1(@types/node@22.15.30)(happy-dom@17.4.9)(jsdom@26.1.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.41.0) @@ -1204,6 +1210,9 @@ importers: drizzle-orm: specifier: ^0.38.3 version: 0.38.3(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/react@19.1.6)(better-sqlite3@11.3.0)(react@18.3.1) + nodemailer: + specifier: ^7.0.4 + version: 7.0.4 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -1229,6 +1238,9 @@ importers: '@types/deep-equal': specifier: ^1.0.4 version: 1.0.4 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 vite-tsconfig-paths: specifier: ^4.3.1 version: 4.3.2(typescript@5.8.2)(vite@5.1.4(@types/node@22.15.30)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.41.0)) @@ -5460,6 +5472,9 @@ packages: '@types/node@22.15.30': resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==} + '@types/nodemailer@6.4.17': + resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -10601,6 +10616,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nodemailer@7.0.4: + resolution: {integrity: sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==} + engines: {node: '>=6.0.0'} + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -21031,6 +21050,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@6.4.17': + dependencies: + '@types/node': 22.15.30 + '@types/parse-json@4.0.2': {} '@types/parse5@6.0.3': {} @@ -27514,6 +27537,8 @@ snapshots: node-releases@2.0.19: {} + nodemailer@7.0.4: {} + nopt@8.1.0: dependencies: abbrev: 3.0.1 |
