Refactor email sending + remove email verification if mail sending is not set up

This commit is contained in:
Madeline 2023-08-12 15:36:29 +10:00
parent ecb227105a
commit d18584f8e9
No known key found for this signature in database
GPG Key ID: 1958E017C36F2E47
7 changed files with 174 additions and 281 deletions

View File

@ -1,84 +1,76 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>Verify {instanceName} Login from New Location</title>
<style> <head>
* { <meta charset="UTF-8" />
font-size: 16px; <meta http-equiv="X-UA-Compatible" content="IE=edge" />
line-height: 24px; <meta name="viewport" content="width=device-width, initial-scale=1.0" />
font-family: Arial, Helvetica, sans-serif; <meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
} <title>Verify {instanceName} Login from New Location</title>
p {
color: white; <style>
} * {
.ExternalClass { font-size: 16px;
width: 100%; line-height: 24px;
} font-family: Arial, Helvetica, sans-serif;
</style> }
</head>
<body> p {
<div style="background-color: #202225;"> color: white;
<img }
src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
alt="Branding" .ExternalClass {
style=" width: 100%;
}
</style>
</head>
<body>
<div style="background-color: #202225;">
<img src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
alt="Branding" style="
width: 100%; width: 100%;
max-width: 200px; max-width: 200px;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
padding: 20px; padding: 20px;
" " />
/> <div style="
<div
style="
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
padding: 40px 50px; padding: 40px 50px;
background-color: #32353b; background-color: #32353b;
border-radius: 5px; border-radius: 5px;
" ">
> <p style="
<p
style="
font-weight: 600; font-weight: 600;
font-size: 20px; font-size: 20px;
letter-spacing: 0.27px; letter-spacing: 0.27px;
line-height: 24px; line-height: 24px;
" ">
> Hey {userUsername},
Hey {userUsername}, </p>
</p> <p>
<p> It looks like someone tried to log into your {instanceName}
It looks like someone tried to log into your {instanceName} account from a new location. If this is you, follow the link
account from a new location. If this is you, follow the link below to authorize logging in from this location on your
below to authorize logging in from this location on your account. If this isn't you, we suggest changing your
account. If this isn't you, we suggest changing your password as soon as possible.
password as soon as possible. </p>
</p> <p>
<p> <strong>IP Address:</strong> {ipAddress}
<strong>IP Address:</strong> {ipAddress} <br />
<br /> <strong>Location:</strong> {locationCity}, {locationRegion},
<strong>Location:</strong> {locationCity}, {locationRegion}, {locationCountryName}
{locationCountryName} </p>
</p> <div>
<div> <div style="
<div
style="
text-align: center; text-align: center;
justify-content: center; justify-content: center;
padding-bottom: 10px; padding-bottom: 10px;
" ">
> <a href="{actionUrl}" target="_blank" style="
<a
href="{verifyUrl}"
target="_blank"
style="
font-size: 15px; font-size: 15px;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
@ -88,26 +80,23 @@
padding: 15px 19px; padding: 15px 19px;
background-color: #0185ff; background-color: #0185ff;
border-radius: 5px; border-radius: 5px;
" ">Verify Login</a>
>Verify Login</a </div>
> <hr />
</div> <div style="
<hr />
<div
style="
text-align: center; text-align: center;
justify-content: center; justify-content: center;
padding-bottom: 10px; padding-bottom: 10px;
" ">
> <p>
<p> Alternatively, you can directly paste this link into
Alternatively, you can directly paste this link into your browser:
your browser: </p>
</p> <a href="{actionUrl}" target="_blank" style="word-wrap: break-word;">{actionUrl}</a>
<a href="{verifyUrl}" target="_blank" style="word-wrap: break-word;">{verifyUrl}</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</body> </div>
</body>
</html> </html>

View File

@ -1,76 +1,68 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>Password Reset Request for {instanceName}</title>
<style> <head>
* { <meta charset="UTF-8" />
font-size: 16px; <meta http-equiv="X-UA-Compatible" content="IE=edge" />
line-height: 24px; <meta name="viewport" content="width=device-width, initial-scale=1.0" />
font-family: Arial, Helvetica, sans-serif; <meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
} <title>Password Reset Request for {instanceName}</title>
p {
color: white; <style>
} * {
.ExternalClass { font-size: 16px;
width: 100%; line-height: 24px;
} font-family: Arial, Helvetica, sans-serif;
</style> }
</head>
<body> p {
<div style="background-color: #202225;"> color: white;
<img }
src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
alt="Branding" .ExternalClass {
style=" width: 100%;
}
</style>
</head>
<body>
<div style="background-color: #202225;">
<img src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
alt="Branding" style="
width: 100%; width: 100%;
max-width: 200px; max-width: 200px;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
padding: 20px; padding: 20px;
" " />
/> <div style="
<div
style="
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
padding: 40px 50px; padding: 40px 50px;
background-color: #32353b; background-color: #32353b;
border-radius: 5px; border-radius: 5px;
" ">
> <p style="
<p
style="
font-weight: 600; font-weight: 600;
font-size: 20px; font-size: 20px;
letter-spacing: 0.27px; letter-spacing: 0.27px;
line-height: 24px; line-height: 24px;
" ">
> Hey {userUsername},
Hey {userUsername}, </p>
</p> <p>
<p> Your {instanceName} password can be reset by clicking the
Your {instanceName} password can be reset by clicking the button below. If you did not request a new password, please
button below. If you did not request a new password, please ignore this email.
ignore this email. </p>
</p> <div>
<div> <div style="
<div
style="
text-align: center; text-align: center;
justify-content: center; justify-content: center;
padding-bottom: 10px; padding-bottom: 10px;
" ">
> <a href="{actionUrl}" target="_blank" style="
<a
href="{passwordResetUrl}"
target="_blank"
style="
font-size: 15px; font-size: 15px;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
@ -80,22 +72,19 @@
padding: 15px 19px; padding: 15px 19px;
background-color: #ff5f00; background-color: #ff5f00;
border-radius: 5px; border-radius: 5px;
" ">Reset Password</a>
>Reset Password</a </div>
> <hr />
</div> <div style="text-align: center">
<hr /> <p>
<div style="text-align: center"> Alternatively, you can directly paste this link into
<p> your browser:
Alternatively, you can directly paste this link into </p>
your browser: <a href="{actionUrl}" target="_blank" style="word-wrap: break-word;">{actionUrl}</a>
</p>
<a href="{passwordResetUrl}" target="_blank" style="word-wrap: break-word;"
>{passwordResetUrl}</a
>
</div>
</div> </div>
</div> </div>
</div> </div>
</body> </div>
</body>
</html> </html>

View File

@ -18,14 +18,13 @@
import { getIpAdress, route, verifyCaptcha } from "@spacebar/api"; import { getIpAdress, route, verifyCaptcha } from "@spacebar/api";
import { import {
adjustEmail,
Config, Config,
FieldErrors, FieldErrors,
generateToken,
generateWebAuthnTicket,
LoginSchema, LoginSchema,
User, User,
WebAuthn, WebAuthn,
generateToken,
generateWebAuthnTicket,
} from "@spacebar/util"; } from "@spacebar/util";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import crypto from "crypto"; import crypto from "crypto";
@ -50,7 +49,6 @@ router.post(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { login, password, captcha_key, undelete } = const { login, password, captcha_key, undelete } =
req.body as LoginSchema; req.body as LoginSchema;
const email = adjustEmail(login);
const config = Config.get(); const config = Config.get();
@ -76,7 +74,7 @@ router.post(
} }
const user = await User.findOneOrFail({ const user = await User.findOneOrFail({
where: [{ phone: login }, { email: email }], where: [{ phone: login }, { email: login }],
select: [ select: [
"data", "data",
"id", "id",

View File

@ -30,7 +30,6 @@ import {
RegisterSchema, RegisterSchema,
User, User,
ValidRegistrationToken, ValidRegistrationToken,
adjustEmail,
generateToken, generateToken,
} from "@spacebar/util"; } from "@spacebar/util";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
@ -76,9 +75,6 @@ router.post(
} }
} }
// email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick
const email = adjustEmail(body.email);
// check if registration is allowed // check if registration is allowed
if (!regTokenUsed && !register.allowNewRegistration) { if (!regTokenUsed && !register.allowNewRegistration) {
throw FieldErrors({ throw FieldErrors({
@ -161,6 +157,7 @@ router.post(
// TODO: gift_code_sku_id? // TODO: gift_code_sku_id?
// TODO: check password strength // TODO: check password strength
const email = body.email;
if (email) { if (email) {
// replace all dots and chars after +, if its a gmail.com email // replace all dots and chars after +, if its a gmail.com email
if (!email) { if (!email) {

View File

@ -18,7 +18,6 @@
import { route } from "@spacebar/api"; import { route } from "@spacebar/api";
import { import {
adjustEmail,
Config, Config,
emitEvent, emitEvent,
FieldErrors, FieldErrors,
@ -111,7 +110,6 @@ router.patch(
} }
if (body.email) { if (body.email) {
body.email = adjustEmail(body.email);
if (!body.email && Config.get().register.email.required) if (!body.email && Config.get().register.email.required)
throw FieldErrors({ throw FieldErrors({
email: { email: {

View File

@ -25,14 +25,7 @@ import {
OneToMany, OneToMany,
OneToOne, OneToOne,
} from "typeorm"; } from "typeorm";
import { import { Config, Email, FieldErrors, Snowflake, trimSpecial } from "..";
Config,
Email,
FieldErrors,
Snowflake,
adjustEmail,
trimSpecial,
} from "..";
import { BitField } from "../util/BitField"; import { BitField } from "../util/BitField";
import { BaseClass } from "./BaseClass"; import { BaseClass } from "./BaseClass";
import { ConnectedAccount } from "./ConnectedAccount"; import { ConnectedAccount } from "./ConnectedAccount";
@ -240,18 +233,6 @@ export class User extends BaseClass {
// TODO: I don't like this method? // TODO: I don't like this method?
validate() { validate() {
if (this.email) {
this.email = adjustEmail(this.email);
if (!this.email)
throw FieldErrors({
email: { message: "Invalid email", code: "EMAIL_INVALID" },
});
if (!this.email.match(/([a-z\d.-]{3,})@([a-z\d.-]+).([a-z]{2,})/g))
throw FieldErrors({
email: { message: "Invalid email", code: "EMAIL_INVALID" },
});
}
if (this.discriminator) { if (this.discriminator) {
const discrim = Number(this.discriminator); const discrim = Number(this.discriminator);
if ( if (

View File

@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import fs from "node:fs"; import fs from "fs/promises";
import path from "node:path"; import path from "node:path";
import { SentMessageInfo, Transporter } from "nodemailer"; import { SentMessageInfo, Transporter } from "nodemailer";
import { User } from "../../entities"; import { User } from "../../entities";
@ -24,8 +24,8 @@ import { Config } from "../Config";
import { generateToken } from "../Token"; import { generateToken } from "../Token";
import MailGun from "./transports/MailGun"; import MailGun from "./transports/MailGun";
import MailJet from "./transports/MailJet"; import MailJet from "./transports/MailJet";
import SendGrid from "./transports/SendGrid";
import SMTP from "./transports/SMTP"; import SMTP from "./transports/SMTP";
import SendGrid from "./transports/SendGrid";
const ASSET_FOLDER_PATH = path.join( const ASSET_FOLDER_PATH = path.join(
__dirname, __dirname,
@ -35,32 +35,11 @@ const ASSET_FOLDER_PATH = path.join(
"..", "..",
"assets", "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 { enum MailTypes {
if (!email) return email; verify = "verify",
// body parser already checked if it is a valid email reset = "reset",
const parts = <RegExpMatchArray>email.match(EMAIL_REGEX); pwchange = "pwchange",
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: { const transporters: {
@ -76,10 +55,15 @@ export const Email: {
transporter: Transporter | null; transporter: Transporter | null;
init: () => Promise<void>; init: () => Promise<void>;
generateLink: ( generateLink: (
type: "verify" | "reset", type: Omit<MailTypes, "pwchange">,
id: string, id: string,
email: string, email: string,
) => Promise<string>; ) => Promise<string>;
sendMail: (
type: MailTypes,
user: User,
email: string,
) => Promise<SentMessageInfo>;
sendVerifyEmail: (user: User, email: string) => Promise<SentMessageInfo>; sendVerifyEmail: (user: User, email: string) => Promise<SentMessageInfo>;
sendResetPassword: (user: User, email: string) => Promise<SentMessageInfo>; sendResetPassword: (user: User, email: string) => Promise<SentMessageInfo>;
sendPasswordChanged: ( sendPasswordChanged: (
@ -89,8 +73,7 @@ export const Email: {
doReplacements: ( doReplacements: (
template: string, template: string,
user: User, user: User,
emailVerificationUrl?: string, actionUrl?: string,
passwordResetUrl?: string,
ipInfo?: { ipInfo?: {
ip: string; ip: string;
city: string; city: string;
@ -119,8 +102,7 @@ export const Email: {
doReplacements: function ( doReplacements: function (
template, template,
user, user,
emailVerificationUrl?, actionUrl?,
passwordResetUrl?,
ipInfo?: { ipInfo?: {
ip: string; ip: string;
city: string; city: string;
@ -137,8 +119,7 @@ export const Email: {
["{userId}", user.id], ["{userId}", user.id],
["{phoneNumber}", user.phone?.slice(-4)], ["{phoneNumber}", user.phone?.slice(-4)],
["{userEmail}", user.email], ["{userEmail}", user.email],
["{emailVerificationUrl}", emailVerificationUrl], ["{actionUrl}", actionUrl],
["{passwordResetUrl}", passwordResetUrl],
["{ipAddress}", ipInfo?.ip], ["{ipAddress}", ipInfo?.ip],
["{locationCity}", ipInfo?.city], ["{locationCity}", ipInfo?.city],
["{locationRegion}", ipInfo?.region], ["{locationRegion}", ipInfo?.region],
@ -165,32 +146,45 @@ export const Email: {
const link = `${instanceUrl}/${type}#token=${token}`; const link = `${instanceUrl}/${type}#token=${token}`;
return link; return link;
}, },
/** /**
* Sends an email to the user with a link to verify their email address *
* @param type the MailType to send
* @param user the user to address it to
* @param email the email to send it to
* @returns
*/ */
sendVerifyEmail: async function (user, email) { sendMail: async function (type, user, email) {
if (!this.transporter) return; if (!this.transporter) return;
// generate a verification link for the user const templateNames: { [key in MailTypes]: string } = {
const link = await this.generateLink("verify", user.id, email); verify: "verify_email.html",
reset: "password_reset_request.html",
pwchange: "password_changed.html",
};
// load the email template const template = await fs.readFile(
const rawTemplate = fs.readFileSync(
path.join( path.join(
ASSET_FOLDER_PATH, ASSET_FOLDER_PATH,
"email_templates", "email_templates",
"verify_email.html", templateNames[type],
), ),
{ encoding: "utf-8" }, { encoding: "utf-8" },
); );
// replace email template placeholders // replace email template placeholders
const html = this.doReplacements(rawTemplate, user, link); const html = this.doReplacements(
template,
user,
// password change emails don't have links
type != MailTypes.pwchange
? await this.generateLink(type, user.id, email)
: undefined,
);
// extract the title from the email template to use as the email subject // extract the title from the email template to use as the email subject
const subject = html.match(/<title>(.*)<\/title>/)?.[1] || ""; const subject = html.match(/<title>(.*)<\/title>/)?.[1] || "";
// construct the email
const message = { const message = {
from: from:
Config.get().general.correspondenceEmail || "noreply@localhost", Config.get().general.correspondenceEmail || "noreply@localhost",
@ -199,78 +193,25 @@ export const Email: {
html, html,
}; };
// send the email
return this.transporter.sendMail(message); return this.transporter.sendMail(message);
}, },
/**
* Sends an email to the user with a link to verify their email address
*/
sendVerifyEmail: async function (user, email) {
return this.sendMail(MailTypes.verify, user, email);
},
/** /**
* Sends an email to the user with a link to reset their password * Sends an email to the user with a link to reset their password
*/ */
sendResetPassword: async function (user, email) { sendResetPassword: async function (user, email) {
if (!this.transporter) return; return this.sendMail(MailTypes.reset, user, email);
// 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 * Sends an email to the user notifying them that their password has been changed
*/ */
sendPasswordChanged: async function (user, email) { sendPasswordChanged: async function (user, email) {
if (!this.transporter) return; return this.sendMail(MailTypes.pwchange, user, email);
// 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);
}, },
}; };