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,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -13,45 +14,41 @@
line-height: 24px; line-height: 24px;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
p { p {
color: white; color: white;
} }
.ExternalClass { .ExternalClass {
width: 100%; width: 100%;
} }
</style> </style>
</head> </head>
<body>
<body>
<div style="background-color: #202225;"> <div style="background-color: #202225;">
<img <img src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg" alt="Branding" style="
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>
@ -68,17 +65,12 @@
{locationCountryName} {locationCountryName}
</p> </p>
<div> <div>
<div <div style="
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> </div>
<hr /> <hr />
<div <div style="
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="{verifyUrl}" target="_blank" style="word-wrap: break-word;">{verifyUrl}</a> <a href="{actionUrl}" target="_blank" style="word-wrap: break-word;">{actionUrl}</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -13,45 +14,41 @@
line-height: 24px; line-height: 24px;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
p { p {
color: white; color: white;
} }
.ExternalClass { .ExternalClass {
width: 100%; width: 100%;
} }
</style> </style>
</head> </head>
<body>
<body>
<div style="background-color: #202225;"> <div style="background-color: #202225;">
<img <img src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg"
src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg" alt="Branding" style="
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>
@ -60,17 +57,12 @@
ignore this email. ignore this email.
</p> </p>
<div> <div>
<div <div style="
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,9 +72,7 @@
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> </div>
<hr /> <hr />
<div style="text-align: center"> <div style="text-align: center">
@ -90,12 +80,11 @@
Alternatively, you can directly paste this link into Alternatively, you can directly paste this link into
your browser: your browser:
</p> </p>
<a href="{passwordResetUrl}" target="_blank" style="word-wrap: break-word;" <a href="{actionUrl}" target="_blank" style="word-wrap: break-word;">{actionUrl}</a>
>{passwordResetUrl}</a
>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</body> </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);
}, },
}; };