diff options
Diffstat (limited to 'packages/trpc')
| -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 |
3 files changed, 201 insertions, 1 deletions
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", + }); + } + }), }); |
