Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email verification #19

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ POSTGRES_PORT=5432

DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}"

SENDGRID_API_KEY=""
VERIFICATION_FROM_EMAIL="example@gmail.com"

# Windows Version:
# Strings must be in double quotes on Windows, and template strings cannot be used
Expand All @@ -15,4 +17,7 @@ DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhos
# POSTGRES_DATABASE="good-dog"
# POSTGRES_PORT=5432

# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog"
# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog"

# SENDGRID_API_KEY=""
# VERIFICATION_FROM_EMAIL="example@gmail.com"
Binary file modified bun.lockb
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "EmailVerificationCode" (
"email" TEXT NOT NULL,
"code" TEXT NOT NULL,
"emailConfirmed" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "EmailVerificationCode_pkey" PRIMARY KEY ("email")
);

-- CreateIndex
CREATE UNIQUE INDEX "EmailVerificationCode_email_key" ON "EmailVerificationCode"("email");
8 changes: 8 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ model Session {
createdAt DateTime @default(now())
expiresAt DateTime
}

model EmailVerificationCode {
email String @id @unique
code String
emailConfirmed Boolean @default(false)
createdAt DateTime @default(now())
expiresAt DateTime
}
9 changes: 9 additions & 0 deletions packages/email/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import baseConfig from "@good-dog/eslint/base";

/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
30 changes: 30 additions & 0 deletions packages/email/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@good-dog/email",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
"./verification-email": "./src/verification-email.ts",
"./email-service": "./src/email-service.ts"
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"devDependencies": {
"@good-dog/eslint": "workspace:*",
"@good-dog/prettier": "workspace:*",
"@good-dog/typescript": "workspace:*",
"eslint": "9.10.0",
"prettier": "3.2.5",
"typescript": "5.4.5"
},
"prettier": "@good-dog/prettier",
"dependencies": {
"@good-dog/env": "workspace:*",
"@sendgrid/mail": "^8.1.4"
}
}
39 changes: 39 additions & 0 deletions packages/email/src/email-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import sgMail from "@sendgrid/mail";

// These functions are an abstraction over the sgMail module from sendgrid. The main
// purpose is to throw runtime errors for blank api keys/from emails so we are alerted of
// the issue ahead of time.

function setApiKey(apiKey: string) {
if (apiKey == "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do just if (!apiKey), which will also catch undefined

throw new TypeError("Invalid api key: Expected a non-empty string.");
}

sgMail.setApiKey(apiKey);
}

interface EmailMessage {
to: string;
from: string;
subject: string;
html: string;
}

async function send(msg: EmailMessage): Promise<boolean> {
if (msg.from == "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing the from in as an arg here, let's just read the env var like we do in setApiKey above

throw new TypeError("Invalid from email: Expected a non-empty string.");
}

try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth throwing the error directly, letting the consumer of the service deal with errors

just repalce the whole try catch with return await sgMail.send(msg);

await sgMail.send(msg);
return true;
} catch (error) {
void error;
return false;
}
}

export default {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of export default just write export function setApiKey...
and export async function send...

setApiKey,
send,
};
18 changes: 18 additions & 0 deletions packages/email/src/verification-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import emailService from "@good-dog/email/email-service";
import { env } from "@good-dog/env";

export async function sendEmailVerification(
toEmail: string,
code: string,
): Promise<boolean> {
emailService.setApiKey(env.SENDGRID_API_KEY ?? "");

const msg = {
to: toEmail,
from: env.VERIFICATION_FROM_EMAIL ?? "",
subject: "Verify Your Email - Good Dog Licensing",
html: `<p>Your Verification Code: <strong>${code}</strong></p>`,
};

return await emailService.send(msg);
}
10 changes: 10 additions & 0 deletions packages/email/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@good-dog/typescript/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"lib": ["es2022"],
"types": ["bun"]
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
5 changes: 5 additions & 0 deletions packages/env/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const env = createEnv({
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
VERIFICATION_FROM_EMAIL: process.env.VERIFICATION_FROM_EMAIL,
},

/**
Expand All @@ -20,6 +22,8 @@ export const env = createEnv({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
SENDGRID_API_KEY: z.string().optional(),
VERIFICATION_FROM_EMAIL: z.string().email().optional(),
},

/**
Expand All @@ -30,6 +34,7 @@ export const env = createEnv({
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
Expand Down
1 change: 1 addition & 0 deletions packages/trpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"dependencies": {
"@good-dog/auth": "workspace:*",
"@good-dog/db": "workspace:*",
"@good-dog/email": "workspace:*",
"@good-dog/env": "workspace:*",
"@tanstack/react-query": "5.56.2",
"@trpc/client": "11.0.0-rc.544",
Expand Down
4 changes: 4 additions & 0 deletions packages/trpc/src/internal/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
confirmEmailProcedure,
deleteAccountProcedure,
sendEmailVerificationProcedure,
signInProcedure,
signOutProcedure,
signUpProcedure,
Expand All @@ -8,6 +10,8 @@ import { getAuthenticatedUserProcedure } from "../procedures/user";
import { createTRPCRouter } from "./init";

export const appRouter = createTRPCRouter({
sendEmailVerification: sendEmailVerificationProcedure,
confirmEmail: confirmEmailProcedure,
signIn: signInProcedure,
signOut: signOutProcedure,
signUp: signUpProcedure,
Expand Down
138 changes: 137 additions & 1 deletion packages/trpc/src/procedures/auth.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend moving these procedures either to their own file individually, or making a new file for email related procedures, this file is getting a bit bloated

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";

import { deleteSessionCookie, setSessionCookie } from "@good-dog/auth/cookies";
import { comparePassword, hashPassword } from "@good-dog/auth/password";
import { sendEmailVerification } from "@good-dog/email/verification-email";

import {
authenticatedProcedureBuilder,
Expand All @@ -12,6 +13,126 @@ import {
const getNewSessionExpirationDate = () =>
new Date(Date.now() + 60_000 * 60 * 24 * 30);

const getNewEmailVerificationCodeExpirationDate = () =>
new Date(Date.now() + 60_000 * 15);

export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder
.input(
z.object({
email: z.string().email(),
}),
)
.mutation(async ({ ctx, input }) => {
// Check if there is already an email verification code for the given email
const existingEmailVerificationCode =
await ctx.prisma.emailVerificationCode.findUnique({
where: {
email: input.email,
},
});
// If email already verified, throw error
if (existingEmailVerificationCode?.emailConfirmed) {
throw new TRPCError({
code: "BAD_REQUEST",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use "CONFLICT" (http code 409) here instead

message: "Email already verified",
});
}
// If email not verified, delete current email verification code to create a new one
if (existingEmailVerificationCode !== null) {
await ctx.prisma.emailVerificationCode.delete({
where: {
email: input.email,
},
});
}

// Generate 6 digit code for email verification
let emailCode = "";
for (let i = 0; i < 6; i++) {
emailCode += Math.floor(Math.random() * 10);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth splitting the code generation here out to a standalone function in the email service. This way we can mock it in testing (and thus remove randomness where needed)

}
// Send email. If sending fails, throw error.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: does this lint error go away after restarting vscode?

if (!(await sendEmailVerification(input.email, emailCode))) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Email confirmation to ${input.email} failed to send.`,
});
}
// Create the email verification code in the database
await ctx.prisma.emailVerificationCode.create({
data: {
code: emailCode,
email: input.email,
expiresAt: getNewEmailVerificationCodeExpirationDate(),
},
});

return {
message: `Email verification code sent to ${input.email}`,
};
});

export const confirmEmailProcedure = notAuthenticatedProcedureBuilder
.input(
z.object({
email: z.string().email(),
code: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Get email verification from database
const emailVerificationCode =
await ctx.prisma.emailVerificationCode.findUnique({
where: {
email: input.email,
},
});

// If email already verified, return a success
if (emailVerificationCode?.emailConfirmed) {
return {
message: `Email was successfully verified. Email: ${input.email}.`,
};
}

// If email verification not found, throw error
if (emailVerificationCode === null) {
throw new TRPCError({
code: "NOT_FOUND",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I think "UNAUTHORIZED" might be better for this error and the subsequent checks, technically we don't necessarily want to be too detailed with failed authentication attempts

message: `${input.email} is not waiting to be confirmed.`,
});
}
// If given code is wrong, throw error
if (input.code !== emailVerificationCode.code) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Given code is incorrect for ${input.email}`,
});
}
// If given code is expired, throw error
if (emailVerificationCode.expiresAt < new Date()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Given code is expired.",
});
}

// If here, the given code was valid, so we can update the emailVerificationCode.
await ctx.prisma.emailVerificationCode.update({
where: {
email: input.email,
},
data: {
emailConfirmed: true,
},
});

return {
message: `Email was successfully verified. Email: ${input.email}.`,
};
});

export const signUpProcedure = notAuthenticatedProcedureBuilder
.input(
z.object({
Expand All @@ -21,6 +142,21 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder
}),
)
.mutation(async ({ ctx, input }) => {
// Throw error if email is not verified
const emailVerificationCode =
await ctx.prisma.emailVerificationCode.findUnique({
where: {
email: input.email,
},
});

if (!emailVerificationCode?.emailConfirmed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Email has not been verified.",
});
}

const existingUserWithEmail = await ctx.prisma.user.findUnique({
where: {
email: input.email,
Expand Down Expand Up @@ -64,7 +200,7 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder
setSessionCookie(session.id, session.expiresAt);

return {
message: `Successfully signed up and logged in as ${input.email}`,
message: `Successfully signed up and logged in as ${input.email}.`,
};
});

Expand Down
Loading