setup react email and nodemailer

This commit is contained in:
Milo Schwartz 2024-10-03 20:55:54 -04:00
parent c9d98a8e8c
commit 57ebc0e525
No known key found for this signature in database
11 changed files with 2497 additions and 4754 deletions

7036
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,14 @@
"db:hydrate": "npx tsx scripts/hydrate.ts",
"db:studio": "drizzle-kit studio",
"build": "next build && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
"start": "ENVIRONMENT=prod node dist/server/index.js"
"start": "ENVIRONMENT=prod node dist/server/index.js",
"email": "email dev --dir emailTemplates --port 3002"
},
"dependencies": {
"@lucia-auth/adapter-drizzle": "1.1.0",
"@node-rs/argon2": "1.8.3",
"@react-email/components": "0.0.25",
"@react-email/tailwind": "0.1.0",
"axios": "1.7.7",
"better-sqlite3": "11.3.0",
"cookie-parser": "1.4.6",
@ -25,6 +28,7 @@
"http-errors": "2.0.0",
"lucia": "3.2.0",
"next": "14.2.13",
"nodemailer": "6.9.15",
"oslo": "1.2.1",
"react": "^18",
"react-dom": "^18",
@ -40,12 +44,14 @@
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/node": "^20",
"@types/nodemailer": "6.4.16",
"@types/react": "^18",
"@types/react-dom": "^18",
"drizzle-kit": "0.24.2",
"eslint": "^8",
"eslint-config-next": "14.2.13",
"postcss": "^8",
"react-email": "3.0.1",
"tailwindcss": "^3.4.1",
"tsc-alias": "1.8.10",
"tsx": "4.19.1",

33
server/emails/index.ts Normal file
View file

@ -0,0 +1,33 @@
export * from "@server/emails/sendEmail";
import nodemailer from "nodemailer";
import environment from "@server/environment";
import logger from "@server/logger";
function createEmailClient() {
if (
!environment.EMAIL_SMTP_HOST ||
!environment.EMAIL_SMTP_PORT ||
!environment.EMAIL_SMTP_USER ||
!environment.EMAIL_SMTP_PASS
) {
logger.warn(
"Email SMTP configuration is missing. Emails will not be sent.",
);
return;
}
return nodemailer.createTransport({
host: environment.EMAIL_SMTP_HOST,
port: environment.EMAIL_SMTP_PORT,
secure: false,
auth: {
user: environment.EMAIL_SMTP_USER,
pass: environment.EMAIL_SMTP_PASS,
},
});
}
export const emailClient = createEmailClient();
export default emailClient;

View file

@ -0,0 +1,33 @@
import { render } from "@react-email/components";
import { ReactElement } from "react";
import emailClient from "@server/emails";
import logger from "@server/logger";
export async function sendEmail(
template: ReactElement,
opts: {
from: string;
to: string;
subject: string;
},
) {
if (!emailClient) {
logger.warn("Email client not configured, skipping email send");
return;
}
const emailHtml = await render(template);
const options = {
from: opts.from,
to: opts.to,
subject: opts.subject,
html: emailHtml,
};
await emailClient.sendMail(options);
logger.debug(`Sent email to ${opts.to}`);
}
export default sendEmail;

View file

@ -0,0 +1,63 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind,
} from "@react-email/components";
import * as React from "react";
interface VerifyEmailProps {
username?: string;
verificationCode: string;
}
export const VerifyEmail = ({
username,
verificationCode,
}: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Verify Your Email
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {username || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
Youve requested to verify your email. Please use
the verification code below:
</Text>
<Section className="text-center my-6">
<Text className="inline-block bg-gray-100 text-xl font-bold text-gray-900 py-2 px-4 border border-gray-300 rounded-md">
{verificationCode}
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2">
If you didnt request this, you can safely ignore
this email.
</Text>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default VerifyEmail;

View file

@ -12,6 +12,7 @@ import {
import internal from "@server/routers/internal";
import { authenticated, unauthenticated } from "@server/routers/external";
import cookieParser from "cookie-parser";
import { User } from "@server/db/schema";
const dev = environment.ENVIRONMENT !== "prod";
@ -67,3 +68,11 @@ app.prepare().then(() => {
);
});
});
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}

View file

@ -1,5 +1,4 @@
import { Request, Response, NextFunction } from "express";
import { verifySession } from "@server/auth";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
@ -7,7 +6,7 @@ import { unauthorized } from "@server/auth";
import { z } from "zod";
import { verify } from "@node-rs/argon2";
import { db } from "@server/db";
import { users } from "@server/db/schema";
import { User, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { response } from "@server/utils";
@ -34,24 +33,9 @@ export async function disable2fa(
}
const { password } = parsedBody.data;
const user = req.user as User;
const { session, user } = await verifySession(req);
if (!session) {
return next(unauthorized());
}
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, user.id));
if (!existingUser || !existingUser[0]) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User does not exist"),
);
}
const validPassword = await verify(existingUser[0].passwordHash, password, {
const validPassword = await verify(user.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,

View file

@ -2,6 +2,8 @@ import { verify } from "@node-rs/argon2";
import lucia, { verifySession } from "@server/auth";
import db from "@server/db";
import { users } from "@server/db/schema";
import { sendEmail } from "@server/emails";
import { VerifyEmail } from "@server/emails/templates/verifyEmailCode";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
import { eq } from "drizzle-orm";

View file

@ -4,10 +4,10 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { encodeHex } from "oslo/encoding";
import HttpCode from "@server/types/HttpCode";
import { verifySession, unauthorized } from "@server/auth";
import { unauthorized } from "@server/auth";
import { response } from "@server/utils";
import { db } from "@server/db";
import { users } from "@server/db/schema";
import { User, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { verify } from "@node-rs/argon2";
import { createTOTPKeyURI } from "oslo/otp";
@ -40,23 +40,9 @@ export async function requestTotpSecret(
const { password } = parsedBody.data;
const { session, user } = await verifySession(req);
if (!session) {
return next(unauthorized());
}
const user = req.user as User;
const existingUser = await db
.select()
.from(users)
.where(eq(users.id, user.id));
if (!existingUser || !existingUser[0]) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User does not exist"),
);
}
const validPassword = await verify(existingUser[0].passwordHash, password, {
const validPassword = await verify(user.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,

View file

@ -5,10 +5,9 @@ import { fromError } from "zod-validation-error";
import { decodeHex } from "oslo/encoding";
import { TOTPController } from "oslo/otp";
import HttpCode from "@server/types/HttpCode";
import { verifySession, unauthorized } from "@server/auth";
import { response } from "@server/utils";
import { db } from "@server/db";
import { users } from "@server/db/schema";
import { User, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
export const verifyTotpBody = z.object({
@ -39,10 +38,7 @@ export async function verifyTotp(
const { code } = parsedBody.data;
const { session, user } = await verifySession(req);
if (!session) {
return next(unauthorized());
}
const user = req.user as User;
if (user.twoFactorEnabled) {
return next(

View file

@ -50,12 +50,9 @@ authenticated.get("/user/:userId", user.getUser);
authenticated.delete("/user/:userId", user.deleteUser);
// Auth routes
const authRouter = Router();
unauthenticated.use("/auth", authRouter);
authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout);
authRouter.post("/verify-totp", auth.verifyTotp);
authRouter.post("/request-totp-secret", auth.requestTotpSecret);
authRouter.post("/disable-2fa", auth.disable2fa);
unauthenticated.put("/auth/signup", auth.signup);
unauthenticated.post("/auth/login", auth.login);
unauthenticated.post("/auth/logout", auth.logout);
authenticated.post("/auth/verify-totp", auth.verifyTotp);
authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret);
authenticated.post("/auth/disable-2fa", auth.disable2fa);