summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-02-24 23:17:36 +1100
committerGitHub <noreply@github.com>2023-02-24 23:17:36 +1100
commit224e2c8374eee1bf85f6798123da4df90daf1860 (patch)
tree3d698d6b7392a061ff04d0dad145200377ed7fad /src
parentFix gateway encoding Date objects as {} when using erlpack. Fixes NaN/NaN/NaN... (diff)
parentmove transporters to their own files (diff)
downloadserver-224e2c8374eee1bf85f6798123da4df90daf1860.tar.xz
Merge pull request #965 from Puyodead1/dev/mail
Email Support
Diffstat (limited to 'src')
-rw-r--r--src/api/Server.ts20
-rw-r--r--src/api/middlewares/Authentication.ts7
-rw-r--r--src/api/routes/auth/forgot.ts92
-rw-r--r--src/api/routes/auth/login.ts12
-rw-r--r--src/api/routes/auth/register.ts11
-rw-r--r--src/api/routes/auth/reset.ts56
-rw-r--r--src/api/routes/auth/verify/index.ts93
-rw-r--r--src/api/routes/auth/verify/resend.ts52
-rw-r--r--src/util/config/Config.ts5
-rw-r--r--src/util/config/types/EmailConfiguration.ts32
-rw-r--r--src/util/config/types/LoginConfiguration.ts1
-rw-r--r--src/util/config/types/PasswordResetConfiguration.ts21
-rw-r--r--src/util/config/types/RegisterConfiguration.ts7
-rw-r--r--src/util/config/types/index.ts4
-rw-r--r--src/util/config/types/subconfigurations/email/MailGun.ts22
-rw-r--r--src/util/config/types/subconfigurations/email/MailJet.ts22
-rw-r--r--src/util/config/types/subconfigurations/email/SMTP.ts25
-rw-r--r--src/util/config/types/subconfigurations/email/SendGrid.ts21
-rw-r--r--src/util/config/types/subconfigurations/email/index.ts21
-rw-r--r--src/util/config/types/subconfigurations/register/Email.ts2
-rw-r--r--src/util/entities/User.ts28
-rw-r--r--src/util/schemas/ForgotPasswordSchema.ts22
-rw-r--r--src/util/schemas/PasswordResetSchema.ts22
-rw-r--r--src/util/schemas/VerifyEmailSchema.ts22
-rw-r--r--src/util/schemas/index.ts20
-rw-r--r--src/util/util/Email.ts45
-rw-r--r--src/util/util/Rights.ts1
-rw-r--r--src/util/util/Token.ts40
-rw-r--r--src/util/util/email/index.ts269
-rw-r--r--src/util/util/email/transports/MailGun.ts36
-rw-r--r--src/util/util/email/transports/MailJet.ts36
-rw-r--r--src/util/util/email/transports/SMTP.ts38
-rw-r--r--src/util/util/email/transports/SendGrid.ts35
-rw-r--r--src/util/util/email/transports/index.ts1
-rw-r--r--src/util/util/index.ts12
35 files changed, 1062 insertions, 91 deletions
diff --git a/src/api/Server.ts b/src/api/Server.ts
index 7eb4e6f1..aec47818 100644
--- a/src/api/Server.ts
+++ b/src/api/Server.ts
@@ -16,28 +16,29 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-import "missing-native-js-functions";
-import { Server, ServerOptions } from "lambert-server";
-import { Authentication, CORS } from "./middlewares/";
 import {
 	Config,
+	Email,
 	initDatabase,
 	initEvent,
 	JSONReplacer,
+	registerRoutes,
 	Sentry,
 	WebAuthn,
 } from "@fosscord/util";
-import { ErrorHandler } from "./middlewares/ErrorHandler";
-import { BodyParser } from "./middlewares/BodyParser";
-import { Router, Request, Response } from "express";
+import { Request, Response, Router } from "express";
+import { Server, ServerOptions } from "lambert-server";
+import "missing-native-js-functions";
+import morgan from "morgan";
 import path from "path";
+import { red } from "picocolors";
+import { Authentication, CORS } from "./middlewares/";
+import { BodyParser } from "./middlewares/BodyParser";
+import { ErrorHandler } from "./middlewares/ErrorHandler";
 import { initRateLimits } from "./middlewares/RateLimit";
 import TestClient from "./middlewares/TestClient";
 import { initTranslation } from "./middlewares/Translation";
-import morgan from "morgan";
 import { initInstance } from "./util/handlers/Instance";
-import { registerRoutes } from "@fosscord/util";
-import { red } from "picocolors";
 
 export type FosscordServerOptions = ServerOptions;
 
@@ -63,6 +64,7 @@ export class FosscordServer extends Server {
 		await initDatabase();
 		await Config.init();
 		await initEvent();
+		await Email.init();
 		await initInstance();
 		await Sentry.init(this.app);
 		WebAuthn.init();
diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts
index ea0aa312..771f0de8 100644
--- a/src/api/middlewares/Authentication.ts
+++ b/src/api/middlewares/Authentication.ts
@@ -16,10 +16,10 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-import { NextFunction, Request, Response } from "express";
-import { HTTPError } from "lambert-server";
 import { checkToken, Config, Rights } from "@fosscord/util";
 import * as Sentry from "@sentry/node";
+import { NextFunction, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
 
 export const NO_AUTHORIZATION_ROUTES = [
 	// Authentication routes
@@ -28,6 +28,9 @@ export const NO_AUTHORIZATION_ROUTES = [
 	"/auth/location-metadata",
 	"/auth/mfa/totp",
 	"/auth/mfa/webauthn",
+	"/auth/verify",
+	"/auth/forgot",
+	"/auth/reset",
 	// Routes with a seperate auth system
 	"/webhooks/",
 	// Public information endpoints
diff --git a/src/api/routes/auth/forgot.ts b/src/api/routes/auth/forgot.ts
new file mode 100644
index 00000000..faa43dbb
--- /dev/null
+++ b/src/api/routes/auth/forgot.ts
@@ -0,0 +1,92 @@
+import { getIpAdress, route, verifyCaptcha } from "@fosscord/api";
+import {
+	Config,
+	Email,
+	FieldErrors,
+	ForgotPasswordSchema,
+	User,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { HTTPError } from "lambert-server";
+const router = Router();
+
+router.post(
+	"/",
+	route({ body: "ForgotPasswordSchema" }),
+	async (req: Request, res: Response) => {
+		const { login, captcha_key } = req.body as ForgotPasswordSchema;
+
+		const config = Config.get();
+
+		if (
+			config.password_reset.requireCaptcha &&
+			config.security.captcha.enabled
+		) {
+			const { sitekey, service } = config.security.captcha;
+			if (!captcha_key) {
+				return res.status(400).json({
+					captcha_key: ["captcha-required"],
+					captcha_sitekey: sitekey,
+					captcha_service: service,
+				});
+			}
+
+			const ip = getIpAdress(req);
+			const verify = await verifyCaptcha(captcha_key, ip);
+			if (!verify.success) {
+				return res.status(400).json({
+					captcha_key: verify["error-codes"],
+					captcha_sitekey: sitekey,
+					captcha_service: service,
+				});
+			}
+		}
+
+		const user = await User.findOneOrFail({
+			where: [{ phone: login }, { email: login }],
+			select: ["username", "id", "disabled", "deleted", "email"],
+			relations: ["security_keys"],
+		}).catch(() => {
+			throw FieldErrors({
+				login: {
+					message: req.t("auth:password_reset.EMAIL_DOES_NOT_EXIST"),
+					code: "EMAIL_DOES_NOT_EXIST",
+				},
+			});
+		});
+
+		if (!user.email)
+			throw FieldErrors({
+				login: {
+					message:
+						"This account does not have an email address associated with it.",
+					code: "NO_EMAIL",
+				},
+			});
+
+		if (user.deleted)
+			return res.status(400).json({
+				message: "This account is scheduled for deletion.",
+				code: 20011,
+			});
+
+		if (user.disabled)
+			return res.status(400).json({
+				message: req.t("auth:login.ACCOUNT_DISABLED"),
+				code: 20013,
+			});
+
+		return await Email.sendResetPassword(user, user.email)
+			.then(() => {
+				return res.sendStatus(204);
+			})
+			.catch((e) => {
+				console.error(
+					`Failed to send password reset email to ${user.username}#${user.discriminator}: ${e}`,
+				);
+				throw new HTTPError("Failed to send password reset email", 500);
+			});
+	},
+);
+
+export default router;
diff --git a/src/api/routes/auth/login.ts b/src/api/routes/auth/login.ts
index 2b97ec10..e6616731 100644
--- a/src/api/routes/auth/login.ts
+++ b/src/api/routes/auth/login.ts
@@ -77,6 +77,7 @@ router.post(
 				"mfa_enabled",
 				"webauthn_enabled",
 				"security_keys",
+				"verified",
 			],
 			relations: ["security_keys"],
 		}).catch(() => {
@@ -102,6 +103,17 @@ router.post(
 			});
 		}
 
+		// return an error for unverified accounts if verification is required
+		if (config.login.requireVerification && !user.verified) {
+			throw FieldErrors({
+				login: {
+					code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL",
+					message:
+						"Email verification is required, please check your email.",
+				},
+			});
+		}
+
 		if (user.mfa_enabled && !user.webauthn_enabled) {
 			// TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy
 			const ticket = crypto.randomBytes(40).toString("hex");
diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts
index 0bf8efae..c941fdf6 100644
--- a/src/api/routes/auth/register.ts
+++ b/src/api/routes/auth/register.ts
@@ -278,6 +278,17 @@ router.post(
 			await Invite.joinGuild(user.id, body.invite);
 		}
 
+		// return an error for unverified accounts if verification is required
+		if (Config.get().login.requireVerification && !user.verified) {
+			throw FieldErrors({
+				login: {
+					code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL",
+					message:
+						"Email verification is required, please check your email.",
+				},
+			});
+		}
+
 		return res.json({ token: await generateToken(user.id) });
 	},
 );
diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts
new file mode 100644
index 00000000..9ab25dca
--- /dev/null
+++ b/src/api/routes/auth/reset.ts
@@ -0,0 +1,56 @@
+import { route } from "@fosscord/api";
+import {
+	checkToken,
+	Config,
+	Email,
+	FieldErrors,
+	generateToken,
+	PasswordResetSchema,
+	User,
+} from "@fosscord/util";
+import bcrypt from "bcrypt";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.post(
+	"/",
+	route({ body: "PasswordResetSchema" }),
+	async (req: Request, res: Response) => {
+		const { password, token } = req.body as PasswordResetSchema;
+
+		const { jwtSecret } = Config.get().security;
+
+		let user;
+		try {
+			const userTokenData = await checkToken(token, jwtSecret, true);
+			user = userTokenData.user;
+		} catch {
+			throw FieldErrors({
+				password: {
+					message: req.t("auth:password_reset.INVALID_TOKEN"),
+					code: "INVALID_TOKEN",
+				},
+			});
+		}
+
+		// the salt is saved in the password refer to bcrypt docs
+		const hash = await bcrypt.hash(password, 12);
+
+		const data = {
+			data: {
+				hash,
+				valid_tokens_since: new Date(),
+			},
+		};
+		await User.update({ id: user.id }, data);
+
+		// come on, the user has to have an email to reset their password in the first place
+		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+		await Email.sendPasswordChanged(user, user.email!);
+
+		res.json({ token: await generateToken(user.id) });
+	},
+);
+
+export default router;
diff --git a/src/api/routes/auth/verify/index.ts b/src/api/routes/auth/verify/index.ts
new file mode 100644
index 00000000..cdbd371a
--- /dev/null
+++ b/src/api/routes/auth/verify/index.ts
@@ -0,0 +1,93 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import { getIpAdress, route, verifyCaptcha } from "@fosscord/api";
+import {
+	checkToken,
+	Config,
+	FieldErrors,
+	generateToken,
+	User,
+} from "@fosscord/util";
+import { Request, Response, Router } from "express";
+const router = Router();
+
+async function getToken(user: User) {
+	const token = await generateToken(user.id);
+
+	// Notice this will have a different token structure, than discord
+	// Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package
+	// https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png
+
+	return { token };
+}
+
+router.post(
+	"/",
+	route({ body: "VerifyEmailSchema" }),
+	async (req: Request, res: Response) => {
+		const { captcha_key, token } = req.body;
+
+		const config = Config.get();
+
+		if (config.register.requireCaptcha) {
+			const { sitekey, service } = config.security.captcha;
+
+			if (!captcha_key) {
+				return res.status(400).json({
+					captcha_key: ["captcha-required"],
+					captcha_sitekey: sitekey,
+					captcha_service: service,
+				});
+			}
+
+			const ip = getIpAdress(req);
+			const verify = await verifyCaptcha(captcha_key, ip);
+			if (!verify.success) {
+				return res.status(400).json({
+					captcha_key: verify["error-codes"],
+					captcha_sitekey: sitekey,
+					captcha_service: service,
+				});
+			}
+		}
+
+		const { jwtSecret } = Config.get().security;
+		let user;
+
+		try {
+			const userTokenData = await checkToken(token, jwtSecret, true);
+			user = userTokenData.user;
+		} catch {
+			throw FieldErrors({
+				password: {
+					message: req.t("auth:password_reset.INVALID_TOKEN"),
+					code: "INVALID_TOKEN",
+				},
+			});
+		}
+
+		if (user.verified) return res.json(await getToken(user));
+
+		await User.update({ id: user.id }, { verified: true });
+
+		return res.json(await getToken(user));
+	},
+);
+
+export default router;
diff --git a/src/api/routes/auth/verify/resend.ts b/src/api/routes/auth/verify/resend.ts
new file mode 100644
index 00000000..918af9a1
--- /dev/null
+++ b/src/api/routes/auth/verify/resend.ts
@@ -0,0 +1,52 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import { route } from "@fosscord/api";
+import { Email, User } from "@fosscord/util";
+import { Request, Response, Router } from "express";
+import { HTTPError } from "lambert-server";
+const router = Router();
+
+router.post(
+	"/",
+	route({ right: "RESEND_VERIFICATION_EMAIL" }),
+	async (req: Request, res: Response) => {
+		const user = await User.findOneOrFail({
+			where: { id: req.user_id },
+			select: ["username", "email"],
+		});
+
+		if (!user.email) {
+			// TODO: whats the proper error response for this?
+			throw new HTTPError("User does not have an email address", 400);
+		}
+
+		await Email.sendVerifyEmail(user, user.email)
+			.then(() => {
+				return res.sendStatus(204);
+			})
+			.catch((e) => {
+				console.error(
+					`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`,
+				);
+				throw new HTTPError("Failed to send verification email", 500);
+			});
+	},
+);
+
+export default router;
diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts
index 122dadb5..c056d454 100644
--- a/src/util/config/Config.ts
+++ b/src/util/config/Config.ts
@@ -21,6 +21,7 @@ import {
 	CdnConfiguration,
 	ClientConfiguration,
 	DefaultsConfiguration,
+	EmailConfiguration,
 	EndpointConfiguration,
 	ExternalTokensConfiguration,
 	GeneralConfiguration,
@@ -30,6 +31,7 @@ import {
 	LimitsConfiguration,
 	LoginConfiguration,
 	MetricsConfiguration,
+	PasswordResetConfiguration,
 	RabbitMQConfiguration,
 	RegionConfiguration,
 	RegisterConfiguration,
@@ -58,4 +60,7 @@ export class ConfigValue {
 	sentry: SentryConfiguration = new SentryConfiguration();
 	defaults: DefaultsConfiguration = new DefaultsConfiguration();
 	external: ExternalTokensConfiguration = new ExternalTokensConfiguration();
+	email: EmailConfiguration = new EmailConfiguration();
+	password_reset: PasswordResetConfiguration =
+		new PasswordResetConfiguration();
 }
diff --git a/src/util/config/types/EmailConfiguration.ts b/src/util/config/types/EmailConfiguration.ts
new file mode 100644
index 00000000..989d59eb
--- /dev/null
+++ b/src/util/config/types/EmailConfiguration.ts
@@ -0,0 +1,32 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import {
+	MailGunConfiguration,
+	MailJetConfiguration,
+	SMTPConfiguration,
+} from "./subconfigurations/email";
+import { SendGridConfiguration } from "./subconfigurations/email/SendGrid";
+
+export class EmailConfiguration {
+	provider: string | null = null;
+	smtp: SMTPConfiguration = new SMTPConfiguration();
+	mailgun: MailGunConfiguration = new MailGunConfiguration();
+	mailjet: MailJetConfiguration = new MailJetConfiguration();
+	sendgrid: SendGridConfiguration = new SendGridConfiguration();
+}
diff --git a/src/util/config/types/LoginConfiguration.ts b/src/util/config/types/LoginConfiguration.ts
index 862bc185..1d5752fe 100644
--- a/src/util/config/types/LoginConfiguration.ts
+++ b/src/util/config/types/LoginConfiguration.ts
@@ -18,4 +18,5 @@
 
 export class LoginConfiguration {
 	requireCaptcha: boolean = false;
+	requireVerification: boolean = false;
 }
diff --git a/src/util/config/types/PasswordResetConfiguration.ts b/src/util/config/types/PasswordResetConfiguration.ts
new file mode 100644
index 00000000..806d77be
--- /dev/null
+++ b/src/util/config/types/PasswordResetConfiguration.ts
@@ -0,0 +1,21 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export class PasswordResetConfiguration {
+	requireCaptcha: boolean = false;
+}
diff --git a/src/util/config/types/RegisterConfiguration.ts b/src/util/config/types/RegisterConfiguration.ts
index acbaa2d5..b8db0077 100644
--- a/src/util/config/types/RegisterConfiguration.ts
+++ b/src/util/config/types/RegisterConfiguration.ts
@@ -18,12 +18,13 @@
 
 import {
 	DateOfBirthConfiguration,
-	EmailConfiguration,
 	PasswordConfiguration,
+	RegistrationEmailConfiguration,
 } from ".";
 
 export class RegisterConfiguration {
-	email: EmailConfiguration = new EmailConfiguration();
+	email: RegistrationEmailConfiguration =
+		new RegistrationEmailConfiguration();
 	dateOfBirth: DateOfBirthConfiguration = new DateOfBirthConfiguration();
 	password: PasswordConfiguration = new PasswordConfiguration();
 	disabled: boolean = false;
@@ -34,5 +35,5 @@ export class RegisterConfiguration {
 	allowMultipleAccounts: boolean = true;
 	blockProxies: boolean = true;
 	incrementingDiscriminators: boolean = false; // random otherwise
-	defaultRights: string = "312119568366592"; // See `npm run generate:rights`
+	defaultRights: string = "875069521787904"; // See `npm run generate:rights`
 }
diff --git a/src/util/config/types/index.ts b/src/util/config/types/index.ts
index 523ad186..510e19f8 100644
--- a/src/util/config/types/index.ts
+++ b/src/util/config/types/index.ts
@@ -20,6 +20,7 @@ export * from "./ApiConfiguration";
 export * from "./CdnConfiguration";
 export * from "./ClientConfiguration";
 export * from "./DefaultsConfiguration";
+export * from "./EmailConfiguration";
 export * from "./EndpointConfiguration";
 export * from "./ExternalTokensConfiguration";
 export * from "./GeneralConfiguration";
@@ -29,10 +30,11 @@ export * from "./KafkaConfiguration";
 export * from "./LimitConfigurations";
 export * from "./LoginConfiguration";
 export * from "./MetricsConfiguration";
+export * from "./PasswordResetConfiguration";
 export * from "./RabbitMQConfiguration";
 export * from "./RegionConfiguration";
 export * from "./RegisterConfiguration";
 export * from "./SecurityConfiguration";
 export * from "./SentryConfiguration";
-export * from "./TemplateConfiguration";
 export * from "./subconfigurations";
+export * from "./TemplateConfiguration";
diff --git a/src/util/config/types/subconfigurations/email/MailGun.ts b/src/util/config/types/subconfigurations/email/MailGun.ts
new file mode 100644
index 00000000..52cd9069
--- /dev/null
+++ b/src/util/config/types/subconfigurations/email/MailGun.ts
@@ -0,0 +1,22 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export class MailGunConfiguration {
+	apiKey: string | null = null;
+	domain: string | null = null;
+}
diff --git a/src/util/config/types/subconfigurations/email/MailJet.ts b/src/util/config/types/subconfigurations/email/MailJet.ts
new file mode 100644
index 00000000..eccda8ac
--- /dev/null
+++ b/src/util/config/types/subconfigurations/email/MailJet.ts
@@ -0,0 +1,22 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export class MailJetConfiguration {
+	apiKey: string | null = null;
+	apiSecret: string | null = null;
+}
diff --git a/src/util/config/types/subconfigurations/email/SMTP.ts b/src/util/config/types/subconfigurations/email/SMTP.ts
new file mode 100644
index 00000000..11eb9e14
--- /dev/null
+++ b/src/util/config/types/subconfigurations/email/SMTP.ts
@@ -0,0 +1,25 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export class SMTPConfiguration {
+	host: string | null = null;
+	port: number | null = null;
+	secure: boolean | null = null;
+	username: string | null = null;
+	password: string | null = null;
+}
diff --git a/src/util/config/types/subconfigurations/email/SendGrid.ts b/src/util/config/types/subconfigurations/email/SendGrid.ts
new file mode 100644
index 00000000..a4755dfb
--- /dev/null
+++ b/src/util/config/types/subconfigurations/email/SendGrid.ts
@@ -0,0 +1,21 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export class SendGridConfiguration {
+	apiKey: string | null = null;
+}
diff --git a/src/util/config/types/subconfigurations/email/index.ts b/src/util/config/types/subconfigurations/email/index.ts
new file mode 100644
index 00000000..02cc564c
--- /dev/null
+++ b/src/util/config/types/subconfigurations/email/index.ts
@@ -0,0 +1,21 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export * from "./MailGun";
+export * from "./MailJet";
+export * from "./SMTP";
diff --git a/src/util/config/types/subconfigurations/register/Email.ts b/src/util/config/types/subconfigurations/register/Email.ts
index 478dc974..4f95caf1 100644
--- a/src/util/config/types/subconfigurations/register/Email.ts
+++ b/src/util/config/types/subconfigurations/register/Email.ts
@@ -16,7 +16,7 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-export class EmailConfiguration {
+export class RegistrationEmailConfiguration {
 	required: boolean = false;
 	allowlist: boolean = false;
 	blocklist: boolean = true;
diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
index 7b67c2ac..f99a85e7 100644
--- a/src/util/entities/User.ts
+++ b/src/util/entities/User.ts
@@ -16,6 +16,7 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
+import { Request } from "express";
 import {
 	Column,
 	Entity,
@@ -24,16 +25,22 @@ import {
 	OneToMany,
 	OneToOne,
 } from "typeorm";
-import { BaseClass } from "./BaseClass";
+import {
+	adjustEmail,
+	Config,
+	Email,
+	FieldErrors,
+	Snowflake,
+	trimSpecial,
+} from "..";
 import { BitField } from "../util/BitField";
-import { Relationship } from "./Relationship";
+import { BaseClass } from "./BaseClass";
 import { ConnectedAccount } from "./ConnectedAccount";
 import { Member } from "./Member";
-import { UserSettings } from "./UserSettings";
-import { Session } from "./Session";
-import { Config, FieldErrors, Snowflake, trimSpecial, adjustEmail } from "..";
-import { Request } from "express";
+import { Relationship } from "./Relationship";
 import { SecurityKey } from "./SecurityKey";
+import { Session } from "./Session";
+import { UserSettings } from "./UserSettings";
 
 export enum PublicUserEnum {
 	username,
@@ -384,6 +391,15 @@ export class User extends BaseClass {
 		user.validate();
 		await Promise.all([user.save(), settings.save()]);
 
+		// send verification email if users aren't verified by default and we have an email
+		if (!Config.get().defaults.user.verified && email) {
+			await Email.sendVerifyEmail(user, email).catch((e) => {
+				console.error(
+					`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`,
+				);
+			});
+		}
+
 		setImmediate(async () => {
 			if (Config.get().guild.autoJoin.enabled) {
 				for (const guild of Config.get().guild.autoJoin.guilds || []) {
diff --git a/src/util/schemas/ForgotPasswordSchema.ts b/src/util/schemas/ForgotPasswordSchema.ts
new file mode 100644
index 00000000..9a28bd18
--- /dev/null
+++ b/src/util/schemas/ForgotPasswordSchema.ts
@@ -0,0 +1,22 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export interface ForgotPasswordSchema {
+	login: string;
+	captcha_key?: string;
+}
diff --git a/src/util/schemas/PasswordResetSchema.ts b/src/util/schemas/PasswordResetSchema.ts
new file mode 100644
index 00000000..9cc74940
--- /dev/null
+++ b/src/util/schemas/PasswordResetSchema.ts
@@ -0,0 +1,22 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export interface PasswordResetSchema {
+	password: string;
+	token: string;
+}
diff --git a/src/util/schemas/VerifyEmailSchema.ts b/src/util/schemas/VerifyEmailSchema.ts
new file mode 100644
index 00000000..d94fbbc1
--- /dev/null
+++ b/src/util/schemas/VerifyEmailSchema.ts
@@ -0,0 +1,22 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+export interface VerifyEmailSchema {
+	captcha_key?: string | null;
+	token: string;
+}
diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts
index 194d8571..44909a3a 100644
--- a/src/util/schemas/index.ts
+++ b/src/util/schemas/index.ts
@@ -16,6 +16,7 @@
 	along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
+export * from "./AckBulkSchema";
 export * from "./ActivitySchema";
 export * from "./ApplicationAuthorizeSchema";
 export * from "./ApplicationCreateSchema";
@@ -32,6 +33,7 @@ export * from "./CodesVerificationSchema";
 export * from "./DmChannelCreateSchema";
 export * from "./EmojiCreateSchema";
 export * from "./EmojiModifySchema";
+export * from "./ForgotPasswordSchema";
 export * from "./GatewayPayloadSchema";
 export * from "./GuildCreateSchema";
 export * from "./GuildTemplateCreateSchema";
@@ -45,8 +47,10 @@ export * from "./MemberChangeProfileSchema";
 export * from "./MemberChangeSchema";
 export * from "./MessageAcknowledgeSchema";
 export * from "./MessageCreateSchema";
+export * from "./MessageEditSchema";
 export * from "./MfaCodesSchema";
 export * from "./ModifyGuildStickerSchema";
+export * from "./PasswordResetSchema";
 export * from "./PurgeSchema";
 export * from "./RegisterSchema";
 export * from "./RelationshipPostSchema";
@@ -69,22 +73,6 @@ export * from "./VanityUrlSchema";
 export * from "./VoiceIdentifySchema";
 export * from "./VoiceStateUpdateSchema";
 export * from "./VoiceVideoSchema";
-export * from "./IdentifySchema";
-export * from "./ActivitySchema";
-export * from "./LazyRequestSchema";
-export * from "./GuildUpdateSchema";
-export * from "./ChannelPermissionOverwriteSchema";
-export * from "./UserGuildSettingsSchema";
-export * from "./GatewayPayloadSchema";
-export * from "./RolePositionUpdateSchema";
-export * from "./ChannelReorderSchema";
-export * from "./UserSettingsSchema";
-export * from "./BotModifySchema";
-export * from "./ApplicationModifySchema";
-export * from "./ApplicationCreateSchema";
-export * from "./ApplicationAuthorizeSchema";
-export * from "./AckBulkSchema";
 export * from "./WebAuthnSchema";
 export * from "./WebhookCreateSchema";
 export * from "./WidgetModifySchema";
-export * from "./MessageEditSchema";
diff --git a/src/util/util/Email.ts b/src/util/util/Email.ts
deleted file mode 100644
index 48d8cae1..00000000
--- a/src/util/util/Email.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
-	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
-	Copyright (C) 2023 Fosscord and Fosscord Contributors
-	
-	This program is free software: you can redistribute it and/or modify
-	it under the terms of the GNU Affero General Public License as published
-	by the Free Software Foundation, either version 3 of the License, or
-	(at your option) any later version.
-	
-	This program is distributed in the hope that it will be useful,
-	but WITHOUT ANY WARRANTY; without even the implied warranty of
-	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-	GNU Affero General Public License for more details.
-	
-	You should have received a copy of the GNU Affero General Public License
-	along with this program.  If not, see <https://www.gnu.org/licenses/>.
-*/
-
-export const EMAIL_REGEX =
-	/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-
-export function adjustEmail(email?: string): string | undefined {
-	if (!email) return email;
-	// body parser already checked if it is a valid email
-	const parts = <RegExpMatchArray>email.match(EMAIL_REGEX);
-	if (!parts || parts.length < 5) return undefined;
-
-	return email;
-	// // TODO: The below code doesn't actually do anything.
-	// const domain = parts[5];
-	// const user = parts[1];
-
-	// // TODO: check accounts with uncommon email domains
-	// if (domain === "gmail.com" || domain === "googlemail.com") {
-	// 	// replace .dots and +alternatives -> Gmail Dot Trick https://support.google.com/mail/answer/7436150 and https://generator.email/blog/gmail-generator
-	// 	const v = user.replace(/[.]|(\+.*)/g, "") + "@gmail.com";
-	// }
-
-	// if (domain === "google.com") {
-	// 	// replace .dots and +alternatives -> Google Staff GMail Dot Trick
-	// 	const v = user.replace(/[.]|(\+.*)/g, "") + "@google.com";
-	// }
-
-	// return email;
-}
diff --git a/src/util/util/Rights.ts b/src/util/util/Rights.ts
index b48477ed..383f07ec 100644
--- a/src/util/util/Rights.ts
+++ b/src/util/util/Rights.ts
@@ -93,6 +93,7 @@ export class Rights extends BitField {
 		EDIT_FLAGS: BitFlag(46), // can set others' flags
 		MANAGE_GROUPS: BitFlag(47), // can manage others' groups
 		VIEW_SERVER_STATS: BitFlag(48), // added per @chrischrome's request — can view server stats)
+		RESEND_VERIFICATION_EMAIL: BitFlag(49), // can resend verification emails (/auth/verify/resend)
 	};
 
 	any(permission: RightResolvable, checkOperator = true) {
diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index ca81eaaa..ffc442aa 100644
--- a/src/util/util/Token.ts
+++ b/src/util/util/Token.ts
@@ -27,9 +27,43 @@ export type UserTokenData = {
 	decoded: { id: string; iat: number };
 };
 
+async function checkEmailToken(
+	decoded: jwt.JwtPayload,
+): Promise<UserTokenData> {
+	// eslint-disable-next-line no-async-promise-executor
+	return new Promise(async (res, rej) => {
+		if (!decoded.iat) return rej("Invalid Token"); // will never happen, just for typings.
+
+		const user = await User.findOne({
+			where: {
+				email: decoded.email,
+			},
+			select: [
+				"email",
+				"id",
+				"verified",
+				"deleted",
+				"disabled",
+				"username",
+				"data",
+			],
+		});
+
+		if (!user) return rej("Invalid Token");
+
+		if (new Date().getTime() > decoded.iat * 1000 + 86400 * 1000)
+			return rej("Invalid Token");
+
+		// Using as here because we assert `id` and `iat` are in decoded.
+		// TS just doesn't want to assume its there, though.
+		return res({ decoded, user } as UserTokenData);
+	});
+}
+
 export function checkToken(
 	token: string,
 	jwtSecret: string,
+	isEmailVerification = false,
 ): Promise<UserTokenData> {
 	return new Promise((res, rej) => {
 		token = token.replace("Bot ", "");
@@ -48,6 +82,8 @@ export function checkToken(
 			)
 				return rej("Invalid Token"); // will never happen, just for typings.
 
+			if (isEmailVerification) return res(checkEmailToken(decoded));
+
 			const user = await User.findOne({
 				where: { id: decoded.id },
 				select: ["data", "bot", "disabled", "deleted", "rights"],
@@ -72,13 +108,13 @@ export function checkToken(
 	});
 }
 
-export async function generateToken(id: string) {
+export async function generateToken(id: string, email?: string) {
 	const iat = Math.floor(Date.now() / 1000);
 	const algorithm = "HS256";
 
 	return new Promise((res, rej) => {
 		jwt.sign(
-			{ id: id, iat },
+			{ id, iat, email },
 			Config.get().security.jwtSecret,
 			{
 				algorithm,
diff --git a/src/util/util/email/index.ts b/src/util/util/email/index.ts
new file mode 100644
index 00000000..8c7a848c
--- /dev/null
+++ b/src/util/util/email/index.ts
@@ -0,0 +1,269 @@
+/*
+	Fosscord: A FOSS re-implementation and extension of the Discord.com backend.
+	Copyright (C) 2023 Fosscord and Fosscord Contributors
+	
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published
+	by the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+	
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+	
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import fs from "node:fs";
+import path from "node:path";
+import { SentMessageInfo, Transporter } from "nodemailer";
+import { User } from "../../entities";
+import { Config } from "../Config";
+import { generateToken } from "../Token";
+import MailGun from "./transports/MailGun";
+import MailJet from "./transports/MailJet";
+import SendGrid from "./transports/SendGrid";
+import SMTP from "./transports/SMTP";
+
+const ASSET_FOLDER_PATH = path.join(__dirname, "..", "..", "..", "assets");
+export const EMAIL_REGEX =
+	/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
+export function adjustEmail(email?: string): string | undefined {
+	if (!email) return email;
+	// body parser already checked if it is a valid email
+	const parts = <RegExpMatchArray>email.match(EMAIL_REGEX);
+	if (!parts || parts.length < 5) return undefined;
+
+	return email;
+	// // TODO: The below code doesn't actually do anything.
+	// const domain = parts[5];
+	// const user = parts[1];
+
+	// // TODO: check accounts with uncommon email domains
+	// if (domain === "gmail.com" || domain === "googlemail.com") {
+	// 	// replace .dots and +alternatives -> Gmail Dot Trick https://support.google.com/mail/answer/7436150 and https://generator.email/blog/gmail-generator
+	// 	const v = user.replace(/[.]|(\+.*)/g, "") + "@gmail.com";
+	// }
+
+	// if (domain === "google.com") {
+	// 	// replace .dots and +alternatives -> Google Staff GMail Dot Trick
+	// 	const v = user.replace(/[.]|(\+.*)/g, "") + "@google.com";
+	// }
+
+	// return email;
+}
+
+const transporters: {
+	[key: string]: () => Promise<Transporter<unknown> | void>;
+} = {
+	smtp: SMTP,
+	mailgun: MailGun,
+	mailjet: MailJet,
+	sendgrid: SendGrid,
+};
+
+export const Email: {
+	transporter: Transporter | null;
+	init: () => Promise<void>;
+	generateLink: (
+		type: "verify" | "reset",
+		id: string,
+		email: string,
+	) => Promise<string>;
+	sendVerifyEmail: (user: User, email: string) => Promise<SentMessageInfo>;
+	sendResetPassword: (user: User, email: string) => Promise<SentMessageInfo>;
+	sendPasswordChanged: (
+		user: User,
+		email: string,
+	) => Promise<SentMessageInfo>;
+	doReplacements: (
+		template: string,
+		user: User,
+		emailVerificationUrl?: string,
+		passwordResetUrl?: string,
+		ipInfo?: {
+			ip: string;
+			city: string;
+			region: string;
+			country_name: string;
+		},
+	) => string;
+} = {
+	transporter: null,
+	init: async function () {
+		const { provider } = Config.get().email;
+		if (!provider) return;
+
+		const transporterFn = transporters[provider];
+		if (!transporterFn)
+			return console.error(`[Email] Invalid provider: ${provider}`);
+		console.log(`[Email] Initializing ${provider} transport...`);
+		const transporter = await transporterFn();
+		if (!transporter) return;
+		this.transporter = transporter;
+		console.log(`[Email] ${provider} transport initialized.`);
+	},
+	/**
+	 * Replaces all placeholders in an email template with the correct values
+	 */
+	doReplacements: function (
+		template,
+		user,
+		emailVerificationUrl?,
+		passwordResetUrl?,
+		ipInfo?: {
+			ip: string;
+			city: string;
+			region: string;
+			country_name: string;
+		},
+	) {
+		const { instanceName } = Config.get().general;
+
+		const replacements = [
+			["{instanceName}", instanceName],
+			["{userUsername}", user.username],
+			["{userDiscriminator}", user.discriminator],
+			["{userId}", user.id],
+			["{phoneNumber}", user.phone?.slice(-4)],
+			["{userEmail}", user.email],
+			["{emailVerificationUrl}", emailVerificationUrl],
+			["{passwordResetUrl}", passwordResetUrl],
+			["{ipAddress}", ipInfo?.ip],
+			["{locationCity}", ipInfo?.city],
+			["{locationRegion}", ipInfo?.region],
+			["{locationCountryName}", ipInfo?.country_name],
+		];
+
+		// loop through all replacements and replace them in the template
+		for (const [key, value] of Object.values(replacements)) {
+			if (!value) continue;
+			template = template.replace(key as string, value);
+		}
+
+		return template;
+	},
+	/**
+	 *
+	 * @param id user id
+	 * @param email user email
+	 */
+	generateLink: async function (type, id, email) {
+		const token = (await generateToken(id, email)) as string;
+		const instanceUrl =
+			Config.get().general.frontPage || "http://localhost:3001";
+		const link = `${instanceUrl}/${type}#token=${token}`;
+		return link;
+	},
+	/**
+	 * Sends an email to the user with a link to verify their email address
+	 */
+	sendVerifyEmail: async function (user, email) {
+		if (!this.transporter) return;
+
+		// generate a verification link for the user
+		const link = await this.generateLink("verify", user.id, email);
+
+		// load the email template
+		const rawTemplate = fs.readFileSync(
+			path.join(
+				ASSET_FOLDER_PATH,
+				"email_templates",
+				"verify_email.html",
+			),
+			{ encoding: "utf-8" },
+		);
+
+		// replace email template placeholders
+		const html = this.doReplacements(rawTemplate, user, link);
+
+		// extract the title from the email template to use as the email subject
+		const subject = html.match(/<title>(.*)<\/title>/)?.[1] || "";
+
+		// construct the email
+		const message = {
+			from:
+				Config.get().general.correspondenceEmail || "noreply@localhost",
+			to: email,
+			subject,
+			html,
+		};
+
+		// send the email
+		return this.transporter.sendMail(message);
+	},
+	/**
+	 * Sends an email to the user with a link to reset their password
+	 */
+	sendResetPassword: async function (user, email) {
+		if (!this.transporter) return;
+
+		// generate a password reset link for the user
+		const link = await this.generateLink("reset", user.id, email);
+
+		// load the email template
+		const rawTemplate = await fs.promises.readFile(
+			path.join(
+				ASSET_FOLDER_PATH,
+				"email_templates",
+				"password_reset_request.html",
+			),
+			{ encoding: "utf-8" },
+		);
+
+		// replace email template placeholders
+		const html = this.doReplacements(rawTemplate, user, undefined, link);
+
+		// extract the title from the email template to use as the email subject
+		const subject = html.match(/<title>(.*)<\/title>/)?.[1] || "";
+
+		// construct the email
+		const message = {
+			from:
+				Config.get().general.correspondenceEmail || "noreply@localhost",
+			to: email,
+			subject,
+			html,
+		};
+
+		// send the email
+		return this.transporter.sendMail(message);
+	},
+	/**
+	 * Sends an email to the user notifying them that their password has been changed
+	 */
+	sendPasswordChanged: async function (user, email) {
+		if (!this.transporter) return;
+
+		// load the email template
+		const rawTemplate = await fs.promises.readFile(
+			path.join(
+				ASSET_FOLDER_PATH,
+				"email_templates",
+				"password_changed.html",
+			),
+			{ encoding: "utf-8" },
+		);
+
+		// replace email template placeholders
+		const html = this.doReplacements(rawTemplate, user);
+
+		// extract the title from the email template to use as the email subject
+		const subject = html.match(/<title>(.*)<\/title>/)?.[1] || "";
+
+		// construct the email
+		const message = {
+			from:
+				Config.get().general.correspondenceEmail || "noreply@localhost",
+			to: email,
+			subject,
+			html,
+		};
+
+		// send the email
+		return this.transporter.sendMail(message);
+	},
+};
diff --git a/src/util/util/email/transports/MailGun.ts b/src/util/util/email/transports/MailGun.ts
new file mode 100644
index 00000000..3a5be13c
--- /dev/null
+++ b/src/util/util/email/transports/MailGun.ts
@@ -0,0 +1,36 @@
+import { Config } from "@fosscord/util";
+import nodemailer from "nodemailer";
+
+export default async function () {
+	// get configuration
+	const { apiKey, domain } = Config.get().email.mailgun;
+
+	// ensure all required configuration values are set
+	if (!apiKey || !domain)
+		return console.error(
+			"[Email] Mailgun has not been configured correctly.",
+		);
+
+	let mg;
+	try {
+		// try to import the transporter package
+		mg = require("nodemailer-mailgun-transport");
+	} catch {
+		// if the package is not installed, log an error and return void so we don't set the transporter
+		console.error(
+			"[Email] Mailgun transport is not installed. Please run `npm install nodemailer-mailgun-transport --save-optional` to install it.",
+		);
+		return;
+	}
+
+	// create the transporter configuration object
+	const auth = {
+		auth: {
+			api_key: apiKey,
+			domain: domain,
+		},
+	};
+
+	// create the transporter and return it
+	return nodemailer.createTransport(mg(auth));
+}
diff --git a/src/util/util/email/transports/MailJet.ts b/src/util/util/email/transports/MailJet.ts
new file mode 100644
index 00000000..561d13ea
--- /dev/null
+++ b/src/util/util/email/transports/MailJet.ts
@@ -0,0 +1,36 @@
+import { Config } from "@fosscord/util";
+import nodemailer from "nodemailer";
+
+export default async function () {
+	// get configuration
+	const { apiKey, apiSecret } = Config.get().email.mailjet;
+
+	// ensure all required configuration values are set
+	if (!apiKey || !apiSecret)
+		return console.error(
+			"[Email] Mailjet has not been configured correctly.",
+		);
+
+	let mj;
+	try {
+		// try to import the transporter package
+		mj = require("nodemailer-mailjet-transport");
+	} catch {
+		// if the package is not installed, log an error and return void so we don't set the transporter
+		console.error(
+			"[Email] Mailjet transport is not installed. Please run `npm install n0script22/nodemailer-mailjet-transport --save-optional` to install it.",
+		);
+		return;
+	}
+
+	// create the transporter configuration object
+	const auth = {
+		auth: {
+			apiKey: apiKey,
+			apiSecret: apiSecret,
+		},
+	};
+
+	// create the transporter and return it
+	return nodemailer.createTransport(mj(auth));
+}
diff --git a/src/util/util/email/transports/SMTP.ts b/src/util/util/email/transports/SMTP.ts
new file mode 100644
index 00000000..7d8e1e20
--- /dev/null
+++ b/src/util/util/email/transports/SMTP.ts
@@ -0,0 +1,38 @@
+import { Config } from "@fosscord/util";
+import nodemailer from "nodemailer";
+
+export default async function () {
+	// get configuration
+	const { host, port, secure, username, password } = Config.get().email.smtp;
+
+	// ensure all required configuration values are set
+	if (!host || !port || secure === null || !username || !password)
+		return console.error("[Email] SMTP has not been configured correctly.");
+
+	if (!Config.get().general.correspondenceEmail)
+		return console.error(
+			"[Email] Correspondence email has not been configured! This is used as the sender email address.",
+		);
+
+	// construct the transporter
+	const transporter = nodemailer.createTransport({
+		host,
+		port,
+		secure,
+		auth: {
+			user: username,
+			pass: password,
+		},
+	});
+
+	// verify connection configuration
+	const verified = await transporter.verify().catch((err) => {
+		console.error("[Email] SMTP verification failed:", err);
+		return;
+	});
+
+	// if verification failed, return void and don't set transporter
+	if (!verified) return;
+
+	return transporter;
+}
diff --git a/src/util/util/email/transports/SendGrid.ts b/src/util/util/email/transports/SendGrid.ts
new file mode 100644
index 00000000..7b46c7be
--- /dev/null
+++ b/src/util/util/email/transports/SendGrid.ts
@@ -0,0 +1,35 @@
+import { Config } from "@fosscord/util";
+import nodemailer from "nodemailer";
+
+export default async function () {
+	// get configuration
+	const { apiKey } = Config.get().email.sendgrid;
+
+	// ensure all required configuration values are set
+	if (!apiKey)
+		return console.error(
+			"[Email] SendGrid has not been configured correctly.",
+		);
+
+	let sg;
+	try {
+		// try to import the transporter package
+		sg = require("nodemailer-sendgrid-transport");
+	} catch {
+		// if the package is not installed, log an error and return void so we don't set the transporter
+		console.error(
+			"[Email] SendGrid transport is not installed. Please run `npm install Maria-Golomb/nodemailer-sendgrid-transport --save-optional` to install it.",
+		);
+		return;
+	}
+
+	// create the transporter configuration object
+	const auth = {
+		auth: {
+			api_key: apiKey,
+		},
+	};
+
+	// create the transporter and return it
+	return nodemailer.createTransport(sg(auth));
+}
diff --git a/src/util/util/email/transports/index.ts b/src/util/util/email/transports/index.ts
new file mode 100644
index 00000000..d14acbf0
--- /dev/null
+++ b/src/util/util/email/transports/index.ts
@@ -0,0 +1 @@
+export * from "./SMTP";
diff --git a/src/util/util/index.ts b/src/util/util/index.ts
index 6eb686d0..93656ecb 100644
--- a/src/util/util/index.ts
+++ b/src/util/util/index.ts
@@ -17,27 +17,27 @@
 */
 
 export * from "./ApiError";
+export * from "./Array";
 export * from "./BitField";
-export * from "./Token";
 //export * from "./Categories";
 export * from "./cdn";
 export * from "./Config";
 export * from "./Constants";
 export * from "./Database";
-export * from "./Email";
+export * from "./email";
 export * from "./Event";
 export * from "./FieldError";
 export * from "./Intents";
+export * from "./InvisibleCharacters";
+export * from "./JSON";
 export * from "./MessageFlags";
 export * from "./Permissions";
 export * from "./RabbitMQ";
 export * from "./Regex";
 export * from "./Rights";
+export * from "./Sentry";
 export * from "./Snowflake";
 export * from "./String";
-export * from "./Array";
+export * from "./Token";
 export * from "./TraverseDirectory";
-export * from "./InvisibleCharacters";
-export * from "./Sentry";
 export * from "./WebAuthn";
-export * from "./JSON";