mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-02-26 07:44:01 +08:00
Custom email (#3964)
* added nodemailer and mjml * added email template * basic email util file * added email queue * email worker * renamed folder * showing which queues and workers were initialized * initializing email on server boot added some test code * renamed to email worker * type fix * renamed queue * added queue to list * added worker to list * logging when config was verified * handling send mail result * not minifying (not supported anymore) using .html property returning correct value * dont send me emails * added port to .env * updated example * updated test email * using env email * parseint base * message * refactor * message * rename * moved email templates * using async file read * typo * using mustache * moved file renamed to email client * logging with prometheus * added social links * line * using stronger types (object instead of array of any[]) moved prometheus logging into email client added function to send mail using a template * fixed templates not working * removed console log * name change * rename * moved templates * rename * string interpolation * string interpolation * moved to dev dependencies, exact versions * moved types * removed function, remove unnecessary if * update template params * updated name * fixed button not clickable * throwing * moved template metadata to one place * rename * sending email on account creation * removed test code removed await * fixed button * not stopping the server if email client validation failed * added metric for queue lengths * exposing getjobcounts * added job to log queue lengths * added endpoint to request verification email * using send verification email that calls api instead of firebase built in * updated route * renamed function * recording time to complete * returning 400 if email already verified * setting transport initialized to true earlier, setting it to false if caught error * moved forgot password flow to a simple popup, added new endpoint to ape * added forgotpasswordemail route * added email tempalte * setting transport to false if caught error * added email queue function * moved try higher * fixed log
This commit is contained in:
parent
8d965a7cf1
commit
1ca0fd1b23
19 changed files with 2419 additions and 96 deletions
backend
email-templates
example.envpackage-lock.jsonpackage.jsonsrc
frontend/src/ts
137
backend/email-templates/reset-password.html
Normal file
137
backend/email-templates/reset-password.html
Normal file
|
@ -0,0 +1,137 @@
|
|||
<mjml>
|
||||
<mj-head>
|
||||
<mj-style>
|
||||
@import
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
|
||||
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
|
||||
!important;}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#323437">
|
||||
<mj-wrapper padding="20px 20px 200px 20px">
|
||||
<mj-section padding="0px" padding-bottom="20px">
|
||||
<mj-column width="600px">
|
||||
<mj-image
|
||||
width="200px"
|
||||
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
|
||||
href="monkeytype.com"
|
||||
align="left"
|
||||
></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="0px">
|
||||
<mj-column background-color="#2c2e31" border-radius="8px">
|
||||
<mj-spacer></mj-spacer>
|
||||
|
||||
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
|
||||
Hey, {{name}}
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Nobody likes being locked out of their account. We're coming to your
|
||||
rescue - just click the button below to get started. If you didn't
|
||||
request a password reset, you can safely ignore this email.
|
||||
</mj-text>
|
||||
|
||||
<mj-button
|
||||
align="left"
|
||||
background-color="#e2b714"
|
||||
color="#323437"
|
||||
font-size="16px"
|
||||
line-height="32px"
|
||||
css-class="btn"
|
||||
href="{{passwordResetLink}}"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Reset your password
|
||||
</mj-button>
|
||||
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Cheers,
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-top="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Monkeytype Team
|
||||
</mj-text>
|
||||
|
||||
<mj-divider border-color="#323437"></mj-divider>
|
||||
|
||||
<mj-text
|
||||
color="#646669"
|
||||
font-size="12px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Alternatively, you can copy and paste the link below into your
|
||||
browser:
|
||||
</mj-text>
|
||||
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
|
||||
{{passwordResetLink}}
|
||||
</mj-text>
|
||||
|
||||
<mj-spacer></mj-spacer>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-bottom="6px" padding-top="20px">
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://github.com/monkeytypegame/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-github"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://twitter.com/monkeytypegame"
|
||||
>
|
||||
<i class="fab fa-fw fa-twitter"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://discord.com/invite/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-discord"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="0">
|
||||
<mj-column>
|
||||
<mj-text align="center" color="#646669" background-color="#323437">
|
||||
monkeytype.com
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
137
backend/email-templates/verification.html
Normal file
137
backend/email-templates/verification.html
Normal file
|
@ -0,0 +1,137 @@
|
|||
<mjml>
|
||||
<mj-head>
|
||||
<mj-style>
|
||||
@import
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css";
|
||||
.btn table{ width: 100%; } .btn a{ width: 100%; padding: 10px 0
|
||||
!important;}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#323437">
|
||||
<mj-wrapper padding="20px 20px 200px 20px">
|
||||
<mj-section padding="0px" padding-bottom="20px">
|
||||
<mj-column width="600px">
|
||||
<mj-image
|
||||
width="200px"
|
||||
src="https://github.com/monkeytypegame/monkeytype/blob/master/frontend/static/images/mtfulllogo.png?raw=true"
|
||||
href="monkeytype.com"
|
||||
align="left"
|
||||
></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="0px">
|
||||
<mj-column background-color="#2c2e31" border-radius="8px">
|
||||
<mj-spacer></mj-spacer>
|
||||
|
||||
<mj-text color="#d1d0c5" font-size="20px" font-family="sans-serif">
|
||||
Hey, {{name}}
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Thanks for joining Monkeytype! We just need one more thing from you
|
||||
- a quick confirmation of your email address and you'll be all set.
|
||||
Click the button below to get started:
|
||||
</mj-text>
|
||||
|
||||
<mj-button
|
||||
align="left"
|
||||
background-color="#e2b714"
|
||||
color="#323437"
|
||||
font-size="16px"
|
||||
line-height="32px"
|
||||
css-class="btn"
|
||||
href="{{verificationLink}}"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Verify
|
||||
</mj-button>
|
||||
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Cheers,
|
||||
</mj-text>
|
||||
<mj-text
|
||||
color="#d1d0c5"
|
||||
font-size="16px"
|
||||
line-height="24px"
|
||||
padding-top="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Monkeytype Team
|
||||
</mj-text>
|
||||
|
||||
<mj-divider border-color="#323437"></mj-divider>
|
||||
|
||||
<mj-text
|
||||
color="#646669"
|
||||
font-size="12px"
|
||||
padding-bottom="0px"
|
||||
font-family="sans-serif"
|
||||
>
|
||||
Alternatively, you can copy and paste the link below into your
|
||||
browser:
|
||||
</mj-text>
|
||||
<mj-text color="#646669" font-size="12px" font-family="sans-serif">
|
||||
{{verificationLink}}
|
||||
</mj-text>
|
||||
|
||||
<mj-spacer></mj-spacer>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-bottom="6px" padding-top="20px">
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://github.com/monkeytypegame/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-github"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://twitter.com/monkeytypegame"
|
||||
>
|
||||
<i class="fab fa-fw fa-twitter"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
<mj-column width="50px">
|
||||
<mj-button
|
||||
font-size="20px"
|
||||
padding="10px"
|
||||
inner-padding="0"
|
||||
color="#d1d0c5"
|
||||
background-color="#323437"
|
||||
href="https://discord.com/invite/monkeytype"
|
||||
>
|
||||
<i class="fab fa-fw fa-discord"></i>
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="0">
|
||||
<mj-column>
|
||||
<mj-text align="center" color="#646669" background-color="#323437">
|
||||
monkeytype.com
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
|
@ -13,3 +13,11 @@ MODE=dev
|
|||
# DB_PASSWORD=
|
||||
# DB_AUTH_MECHANISM="SCRAM-SHA-256"
|
||||
# DB_AUTH_SOURCE=admin
|
||||
|
||||
# You can get a testing email address over at
|
||||
# https://ethereal.email/create
|
||||
#
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_HOST=smtp.ethereal.email
|
||||
# EMAIL_USER=
|
||||
# EMAIL_PASS=
|
||||
|
|
1731
backend/package-lock.json
generated
1731
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -30,8 +30,11 @@
|
|||
"joi": "17.6.0",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "7.10.1",
|
||||
"mjml": "4.13.0",
|
||||
"mongodb": "4.4.0",
|
||||
"mustache": "4.2.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"nodemailer": "6.9.1",
|
||||
"nodemon": "2.0.17",
|
||||
"object-hash": "3.0.0",
|
||||
"path": "0.12.7",
|
||||
|
@ -53,8 +56,10 @@
|
|||
"@types/ioredis": "4.28.10",
|
||||
"@types/jest": "27.5.0",
|
||||
"@types/lodash": "4.14.178",
|
||||
"@types/mustache": "4.2.2",
|
||||
"@types/node": "17.0.18",
|
||||
"@types/node-fetch": "2.6.1",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/object-hash": "2.2.1",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@types/swagger-stats": "0.95.4",
|
||||
|
|
|
@ -19,6 +19,7 @@ import * as RedisClient from "../../init/redis";
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
import emailQueue from "../../queues/email-queue";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
|
@ -58,6 +59,59 @@ export async function createNewUser(
|
|||
return new MonkeyResponse("User created");
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { email, uid } = req.ctx.decodedToken;
|
||||
const isVerified = (await admin.auth().getUser(uid)).emailVerified;
|
||||
if (isVerified === true) {
|
||||
throw new MonkeyError(400, "Email already verified");
|
||||
}
|
||||
|
||||
const userInfo = await UserDAL.getUser(uid, "request verification email");
|
||||
|
||||
const link = await admin.auth().generateEmailVerificationLink(email, {
|
||||
url:
|
||||
process.env.MODE === "dev"
|
||||
? "http://localhost:3000"
|
||||
: "https://monkeytype.com",
|
||||
});
|
||||
await emailQueue.sendVerificationEmail(email, userInfo.name, link);
|
||||
|
||||
return new MonkeyResponse("Email sent");
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { email } = req.body;
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = await admin.auth().getUserByEmail(email);
|
||||
} catch (e) {
|
||||
if (e.code === "auth/user-not-found") {
|
||||
throw new MonkeyError(404, "User not found");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const userInfo = await UserDAL.getUser(
|
||||
auth.uid,
|
||||
"request forgot password email"
|
||||
);
|
||||
|
||||
const link = await admin.auth().generatePasswordResetLink(email, {
|
||||
url:
|
||||
process.env.MODE === "dev"
|
||||
? "http://localhost:3000"
|
||||
: "https://monkeytype.com",
|
||||
});
|
||||
await emailQueue.sendForgotPasswordEmail(email, userInfo.name, link);
|
||||
|
||||
return new MonkeyResponse("Email sent if user was found");
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
|
|
|
@ -582,4 +582,22 @@ router.post(
|
|||
asyncHandler(UserController.reportUser)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/verificationEmail",
|
||||
authenticateRequest(),
|
||||
RateLimit.userRequestVerificationEmail,
|
||||
asyncHandler(UserController.sendVerificationEmail)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/forgotPasswordEmail",
|
||||
RateLimit.userForgotPasswordEmail,
|
||||
validateRequest({
|
||||
body: {
|
||||
email: joi.string().email().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(UserController.sendForgotPasswordEmail)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
141
backend/src/init/email-client.ts
Normal file
141
backend/src/init/email-client.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import * as nodemailer from "nodemailer";
|
||||
import Logger from "../utils/logger";
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import mjml2html from "mjml";
|
||||
import mustache from "mustache";
|
||||
import { recordEmail } from "../utils/prometheus";
|
||||
import { EmailTaskContexts, EmailType } from "../queues/email-queue";
|
||||
|
||||
interface EmailMetadata {
|
||||
subject: string;
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
const templates: Record<EmailType, EmailMetadata> = {
|
||||
verify: {
|
||||
subject: "Verify your Monkeytype account",
|
||||
templateName: "verification.html",
|
||||
},
|
||||
resetPassword: {
|
||||
subject: "Reset your Monkeytype password",
|
||||
templateName: "reset-password.html",
|
||||
},
|
||||
};
|
||||
|
||||
let transportInitialized = false;
|
||||
let transporter: nodemailer.Transporter;
|
||||
|
||||
export function isInitialized(): boolean {
|
||||
return transportInitialized;
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
if (isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { EMAIL_HOST, EMAIL_USER, EMAIL_PASS, EMAIL_PORT, MODE } = process.env;
|
||||
|
||||
if (!EMAIL_HOST || !EMAIL_USER || !EMAIL_PASS) {
|
||||
if (MODE === "dev") {
|
||||
Logger.warning(
|
||||
"No email client configuration provided. Running without email."
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("No email client configuration provided");
|
||||
}
|
||||
|
||||
try {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: EMAIL_HOST,
|
||||
secure: EMAIL_PORT === "465" ? true : false,
|
||||
port: parseInt(EMAIL_PORT ?? "578", 10),
|
||||
auth: {
|
||||
user: EMAIL_USER,
|
||||
pass: EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
transportInitialized = true;
|
||||
|
||||
Logger.info("Verifying email client configuration...");
|
||||
const result = await transporter.verify();
|
||||
|
||||
if (result !== true) {
|
||||
throw new Error(
|
||||
`Could not verify email client configuration: ` + JSON.stringify(result)
|
||||
);
|
||||
}
|
||||
|
||||
Logger.success("Email client configuration verified");
|
||||
} catch (error) {
|
||||
transportInitialized = false;
|
||||
Logger.error(error.message);
|
||||
Logger.error("Failed to verify email client configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
interface MailResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function sendEmail<M extends EmailType>(
|
||||
templateName: EmailType,
|
||||
to: string,
|
||||
data: EmailTaskContexts[M]
|
||||
): Promise<MailResult> {
|
||||
if (!isInitialized()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Email client transport not initialized",
|
||||
};
|
||||
}
|
||||
|
||||
const template = await fillTemplate<typeof templateName>(templateName, data);
|
||||
|
||||
const mailOptions = {
|
||||
from: "Monkeytype <noreply@monkeytype.com>",
|
||||
to,
|
||||
subject: templates[templateName].subject,
|
||||
html: template,
|
||||
};
|
||||
|
||||
const result = await transporter.sendMail(mailOptions);
|
||||
|
||||
recordEmail(templateName, result.accepted.length === 0 ? "fail" : "success");
|
||||
|
||||
return {
|
||||
success: result.accepted.length !== 0,
|
||||
message: result.response,
|
||||
};
|
||||
}
|
||||
|
||||
const EMAIL_TEMPLATES_DIRECTORY = join(__dirname, "../../email-templates");
|
||||
|
||||
const cachedTemplates: Record<string, string> = {};
|
||||
|
||||
async function getTemplate(name: string): Promise<string> {
|
||||
if (cachedTemplates[name]) {
|
||||
return cachedTemplates[name];
|
||||
}
|
||||
|
||||
const template = await fs.promises.readFile(
|
||||
`${EMAIL_TEMPLATES_DIRECTORY}/${name}`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const html = mjml2html(template).html;
|
||||
|
||||
cachedTemplates[name] = html;
|
||||
return html;
|
||||
}
|
||||
|
||||
async function fillTemplate<M extends EmailType>(
|
||||
type: M,
|
||||
data: EmailTaskContexts[M]
|
||||
): Promise<string> {
|
||||
const template = await getTemplate(templates[type].templateName);
|
||||
return mustache.render(template, data);
|
||||
}
|
|
@ -441,6 +441,20 @@ export const userDiscordUnlink = rateLimit({
|
|||
handler: customHandler,
|
||||
});
|
||||
|
||||
export const userRequestVerificationEmail = rateLimit({
|
||||
windowMs: ONE_HOUR_MS / 4,
|
||||
max: 1 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
|
||||
export const userForgotPasswordEmail = rateLimit({
|
||||
windowMs: ONE_HOUR_MS / 4,
|
||||
max: 1 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
|
||||
export const userProfileGet = rateLimit({
|
||||
windowMs: ONE_HOUR_MS,
|
||||
max: 100 * REQUEST_MULTIPLIER,
|
||||
|
|
68
backend/src/queues/email-queue.ts
Normal file
68
backend/src/queues/email-queue.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { MonkeyQueue } from "./monkey-queue";
|
||||
|
||||
const QUEUE_NAME = "email-tasks";
|
||||
|
||||
export type EmailType = "verify" | "resetPassword";
|
||||
|
||||
export interface EmailTask<M extends EmailType> {
|
||||
type: M;
|
||||
email: string;
|
||||
ctx: EmailTaskContexts[M];
|
||||
}
|
||||
|
||||
export type EmailTaskContexts = {
|
||||
verify: {
|
||||
name: string;
|
||||
verificationLink: string;
|
||||
};
|
||||
resetPassword: {
|
||||
name: string;
|
||||
passwordResetLink: string;
|
||||
};
|
||||
};
|
||||
|
||||
function buildTask(
|
||||
taskName: EmailType,
|
||||
email: string,
|
||||
taskContext: EmailTaskContexts[EmailType]
|
||||
): EmailTask<EmailType> {
|
||||
return {
|
||||
type: taskName,
|
||||
email: email,
|
||||
ctx: taskContext,
|
||||
};
|
||||
}
|
||||
|
||||
class EmailQueue extends MonkeyQueue<EmailTask<EmailType>> {
|
||||
async sendVerificationEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
verificationLink: string
|
||||
): Promise<void> {
|
||||
const taskName = "verify";
|
||||
const task = buildTask(taskName, email, { name, verificationLink });
|
||||
await this.add(taskName, task);
|
||||
}
|
||||
|
||||
async sendForgotPasswordEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
passwordResetLink: string
|
||||
): Promise<void> {
|
||||
const taskName = "resetPassword";
|
||||
const task = buildTask(taskName, email, { name, passwordResetLink });
|
||||
await this.add(taskName, task);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EmailQueue(QUEUE_NAME, {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 2000,
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,4 +1,5 @@
|
|||
import LaterQueue from "./later-queue";
|
||||
import GeorgeQueue from "./george-queue";
|
||||
import EmailQueue from "./email-queue";
|
||||
|
||||
export default [GeorgeQueue, LaterQueue];
|
||||
export default [GeorgeQueue, LaterQueue, EmailQueue];
|
||||
|
|
|
@ -14,6 +14,7 @@ import * as RedisClient from "./init/redis";
|
|||
import queues from "./queues";
|
||||
import workers from "./workers";
|
||||
import Logger from "./utils/logger";
|
||||
import * as EmailClient from "./init/email-client";
|
||||
|
||||
async function bootServer(port: number): Promise<Server> {
|
||||
try {
|
||||
|
@ -35,6 +36,9 @@ async function bootServer(port: number): Promise<Server> {
|
|||
const liveConfiguration = await getLiveConfiguration();
|
||||
Logger.success("Live configuration fetched");
|
||||
|
||||
Logger.info("Initializing email client...");
|
||||
EmailClient.init();
|
||||
|
||||
Logger.info("Connecting to redis...");
|
||||
await RedisClient.connect();
|
||||
|
||||
|
@ -46,13 +50,21 @@ async function bootServer(port: number): Promise<Server> {
|
|||
queues.forEach((queue) => {
|
||||
queue.init(connection);
|
||||
});
|
||||
Logger.success("Queues initialized");
|
||||
Logger.success(
|
||||
`Queues initialized: ${queues
|
||||
.map((queue) => queue.queueName)
|
||||
.join(", ")}`
|
||||
);
|
||||
|
||||
Logger.info("Initializing workers...");
|
||||
workers.forEach((worker) => {
|
||||
worker(connection).run();
|
||||
});
|
||||
Logger.success("Workers initialized");
|
||||
Logger.success(
|
||||
`Workers initialized: ${workers
|
||||
.map((worker) => worker(connection).name)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
initializeDailyLeaderboardsCache(liveConfiguration.dailyLeaderboards);
|
||||
|
|
|
@ -292,6 +292,16 @@ export function setQueueLength(
|
|||
queueLength.set({ queueName, countType }, length);
|
||||
}
|
||||
|
||||
const emailCount = new Counter({
|
||||
name: "email_count",
|
||||
help: "Emails sent by the server",
|
||||
labelNames: ["type", "status"],
|
||||
});
|
||||
|
||||
export function recordEmail(type: string, status: string): void {
|
||||
emailCount.inc({ type, status });
|
||||
}
|
||||
|
||||
const timeToCompleteJobTotal = new Counter({
|
||||
name: "time_to_complete_job_total",
|
||||
help: "Time to complete a job total",
|
||||
|
|
36
backend/src/workers/email-worker.ts
Normal file
36
backend/src/workers/email-worker.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import _ from "lodash";
|
||||
import IORedis from "ioredis";
|
||||
import { Worker, Job } from "bullmq";
|
||||
import Logger from "../utils/logger";
|
||||
import EmailQueue, {
|
||||
EmailTaskContexts,
|
||||
EmailType,
|
||||
} from "../queues/email-queue";
|
||||
import { sendEmail } from "../init/email-client";
|
||||
import { recordTimeToCompleteJob } from "../utils/prometheus";
|
||||
|
||||
async function jobHandler(job: Job): Promise<void> {
|
||||
const type: EmailType = job.data.type;
|
||||
const email: string = job.data.email;
|
||||
const ctx: EmailTaskContexts[typeof type] = job.data.ctx;
|
||||
|
||||
Logger.info(`Starting job: ${type}`);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const result = await sendEmail(type, email, ctx);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start;
|
||||
recordTimeToCompleteJob(EmailQueue.queueName, type, elapsed);
|
||||
Logger.success(`Job: ${type} - completed in ${elapsed}ms`);
|
||||
}
|
||||
|
||||
export default (redisConnection?: IORedis.Redis): Worker =>
|
||||
new Worker(EmailQueue.queueName, jobHandler, {
|
||||
autorun: false,
|
||||
connection: redisConnection,
|
||||
});
|
|
@ -1,3 +1,4 @@
|
|||
import LaterWorker from "./later-worker";
|
||||
import EmailWorker from "./email-worker";
|
||||
|
||||
export default [LaterWorker];
|
||||
export default [LaterWorker, EmailWorker];
|
||||
|
|
|
@ -241,4 +241,14 @@ export default class Users {
|
|||
|
||||
return await this.httpClient.post(`${BASE_PATH}/report`, { payload });
|
||||
}
|
||||
|
||||
async verificationEmail(): Ape.EndpointData {
|
||||
return await this.httpClient.get(`${BASE_PATH}/verificationEmail`);
|
||||
}
|
||||
|
||||
async forgotPasswordEmail(email: string): Ape.EndpointData {
|
||||
return await this.httpClient.post(`${BASE_PATH}/forgotPasswordEmail`, {
|
||||
payload: { email },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ import {
|
|||
browserSessionPersistence,
|
||||
browserLocalPersistence,
|
||||
createUserWithEmailAndPassword,
|
||||
sendEmailVerification,
|
||||
signInWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
setPersistence,
|
||||
|
@ -35,7 +34,6 @@ import {
|
|||
linkWithCredential,
|
||||
reauthenticateWithPopup,
|
||||
getAdditionalUserInfo,
|
||||
sendPasswordResetEmail,
|
||||
User as UserType,
|
||||
Unsubscribe,
|
||||
} from "firebase/auth";
|
||||
|
@ -50,26 +48,25 @@ import { update as updateTagsCommands } from "../commandline/lists/tags";
|
|||
import * as ConnectionState from "../states/connection";
|
||||
|
||||
export const gmailProvider = new GoogleAuthProvider();
|
||||
let canCall = true;
|
||||
|
||||
export function sendVerificationEmail(): void {
|
||||
export async function sendVerificationEmail(): Promise<void> {
|
||||
if (Auth === undefined) {
|
||||
Notifications.add("Authentication uninitialized", -1, 3);
|
||||
return;
|
||||
}
|
||||
|
||||
Loader.show();
|
||||
const user = Auth.currentUser;
|
||||
if (user === null) return;
|
||||
sendEmailVerification(user)
|
||||
.then(() => {
|
||||
Loader.hide();
|
||||
Notifications.add("Email sent to " + user.email, 4000);
|
||||
})
|
||||
.catch((e) => {
|
||||
Loader.hide();
|
||||
Notifications.add("Error: " + e.message, 3000);
|
||||
console.error(e.message);
|
||||
});
|
||||
const result = await Ape.users.verificationEmail();
|
||||
if (result.status !== 200) {
|
||||
Loader.hide();
|
||||
Notifications.add(
|
||||
"Failed to request verification email: " + result.message,
|
||||
3000
|
||||
);
|
||||
} else {
|
||||
Loader.hide();
|
||||
Notifications.add("Verification email sent", 1, 3);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDataAndInit(): Promise<boolean> {
|
||||
|
@ -241,12 +238,6 @@ export async function getDataAndInit(): Promise<boolean> {
|
|||
|
||||
export async function loadUser(user: UserType): Promise<void> {
|
||||
// User is signed in.
|
||||
$(".pageAccount .content p.accountVerificatinNotice").remove();
|
||||
if (user.emailVerified === false) {
|
||||
$(".pageAccount .content").prepend(
|
||||
`<p class="accountVerificatinNotice" style="text-align:center">Your account is not verified. <a class="sendVerificationEmail">Send the verification email again</a>.`
|
||||
);
|
||||
}
|
||||
PageTransition.set(false);
|
||||
AccountButton.loading(true);
|
||||
if ((await getDataAndInit()) === false) {
|
||||
|
@ -413,35 +404,6 @@ export async function signIn(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: any): Promise<void> {
|
||||
if (Auth === undefined) {
|
||||
Notifications.add("Authentication uninitialized", -1, 3);
|
||||
return;
|
||||
}
|
||||
if (!canCall) {
|
||||
return Notifications.add(
|
||||
"Please wait before requesting another password reset link",
|
||||
0,
|
||||
5000
|
||||
);
|
||||
}
|
||||
if (!email) return Notifications.add("Please enter an email!", -1);
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(Auth, email);
|
||||
Notifications.add("Email sent", 1, 2);
|
||||
} catch (error) {
|
||||
Notifications.add(
|
||||
Misc.createErrorMessage(error, "Failed to send email"),
|
||||
-1
|
||||
);
|
||||
}
|
||||
canCall = false;
|
||||
setTimeout(function () {
|
||||
canCall = true;
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
if (Auth === undefined) {
|
||||
Notifications.add("Authentication uninitialized", -1, 3);
|
||||
|
@ -676,7 +638,7 @@ async function signUp(): Promise<void> {
|
|||
}
|
||||
|
||||
await updateProfile(createdAuthUser.user, { displayName: nname });
|
||||
await sendEmailVerification(createdAuthUser.user);
|
||||
await sendVerificationEmail();
|
||||
AllTimeStats.clear();
|
||||
$("#menu .textButton.account .text").text(nname);
|
||||
$(".pageLogin .button").removeClass("disabled");
|
||||
|
@ -725,13 +687,6 @@ async function signUp(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
$(".pageLogin #forgotPasswordButton").on("click", () => {
|
||||
const emailField =
|
||||
($(".pageLogin .login input")[0] as HTMLInputElement).value || "";
|
||||
const email = prompt("Email address", emailField);
|
||||
forgotPassword(email);
|
||||
});
|
||||
|
||||
$(".pageLogin .login input").keyup((e) => {
|
||||
if (e.key === "Enter") {
|
||||
UpdateConfig.setChangedBeforeDb(false);
|
||||
|
|
|
@ -17,6 +17,7 @@ import format from "date-fns/format";
|
|||
import * as ConnectionState from "../states/connection";
|
||||
import * as Skeleton from "../popups/skeleton";
|
||||
import type { ScaleChartOptions } from "chart.js";
|
||||
import { Auth } from "../firebase";
|
||||
|
||||
let filterDebug = false;
|
||||
//toggle filterdebug
|
||||
|
@ -1278,6 +1279,12 @@ export const page = new Page(
|
|||
await update();
|
||||
await Misc.sleep(0);
|
||||
updateChartColors();
|
||||
$(".pageAccount .content p.accountVerificatinNotice").remove();
|
||||
if (Auth?.currentUser?.emailVerified === false) {
|
||||
$(".pageAccount .content").prepend(
|
||||
`<p class="accountVerificatinNotice" style="text-align:center">Your account is not verified. <a class="sendVerificationEmail">Send the verification email again</a>.`
|
||||
);
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
//
|
||||
|
|
|
@ -1241,6 +1241,50 @@ list["deleteCustomTheme"] = new SimplePopup(
|
|||
}
|
||||
);
|
||||
|
||||
list["forgotPassword"] = new SimplePopup(
|
||||
"forgotPassword",
|
||||
"text",
|
||||
"Forgot Password",
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
placeholder: "Email",
|
||||
initVal: "",
|
||||
},
|
||||
],
|
||||
"",
|
||||
"Send",
|
||||
async (_thisPopup, email) => {
|
||||
Loader.show();
|
||||
const result = await Ape.users.forgotPasswordEmail(email);
|
||||
if (result.status !== 200) {
|
||||
Loader.hide();
|
||||
Notifications.add(
|
||||
"Failed to request password reset email: " + result.message,
|
||||
5000
|
||||
);
|
||||
} else {
|
||||
Loader.hide();
|
||||
Notifications.add("Password reset email sent", 1, 3);
|
||||
}
|
||||
},
|
||||
(thisPopup) => {
|
||||
const inputValue = $(
|
||||
`.pageLogin .login input[name="current-email"]`
|
||||
).val() as string;
|
||||
if (inputValue) {
|
||||
thisPopup.inputs[0].initVal = inputValue;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
//
|
||||
}
|
||||
);
|
||||
|
||||
$(".pageLogin #forgotPasswordButton").on("click", () => {
|
||||
list["forgotPassword"].show();
|
||||
});
|
||||
|
||||
$(".pageSettings .section.discordIntegration #unlinkDiscordButton").on(
|
||||
"click",
|
||||
() => {
|
||||
|
|
Loading…
Reference in a new issue