aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/email.ts110
-rw-r--r--packages/trpc/package.json2
-rw-r--r--packages/trpc/routers/users.ts90
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",
+ });
+ }
+ }),
});