feat: Automatic invite reminder email (#3354)
* feat: Add user flags concept, for tracking bits on a user * feat: Example flag usage for user invite resend abuse * wip * test * fix: Set correct flag
This commit is contained in:
@@ -62,6 +62,7 @@ export default async function userInviter({
|
||||
service: null,
|
||||
isAdmin: invite.role === "admin",
|
||||
isViewer: invite.role === "viewer",
|
||||
invitedById: user.id,
|
||||
flags: {
|
||||
[UserFlag.InviteSent]: 1,
|
||||
},
|
||||
|
||||
70
server/emails/templates/InviteReminderEmail.tsx
Normal file
70
server/emails/templates/InviteReminderEmail.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import BaseEmail from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type Props = {
|
||||
to: string;
|
||||
name: string;
|
||||
actorName: string;
|
||||
actorEmail: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Email sent to an external user when an admin sends them an invite and they
|
||||
* haven't signed in after a few days.
|
||||
*/
|
||||
export default class InviteReminderEmail extends BaseEmail<Props> {
|
||||
protected subject({ actorName, teamName }: Props) {
|
||||
return `Reminder: ${actorName} invited you to join ${teamName}’s knowledge base`;
|
||||
}
|
||||
|
||||
protected preview() {
|
||||
return "Outline is a place for your team to build and share knowledge.";
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
teamName,
|
||||
actorName,
|
||||
actorEmail,
|
||||
teamUrl,
|
||||
}: Props): string {
|
||||
return `
|
||||
This is just a quick reminder that ${actorName} (${actorEmail}) invited you to join them in the ${teamName} team on Outline, a place for your team to build and share knowledge.
|
||||
We only send a reminder once.
|
||||
|
||||
If you haven't signed up yet, you can do so here: ${teamUrl}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ teamName, actorName, actorEmail, teamUrl }: Props) {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>Join {teamName} on Outline</Heading>
|
||||
<p>
|
||||
This is just a quick reminder that {actorName} ({actorEmail})
|
||||
invited you to join them in the {teamName} team on Outline, a place
|
||||
for your team to build and share knowledge.
|
||||
</p>
|
||||
<p>If you haven't signed up yet, you can do so here:</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ module.exports = {
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
down: async (queryInterface) => {
|
||||
return queryInterface.removeColumn("users", "flags");
|
||||
}
|
||||
};
|
||||
|
||||
17
server/migrations/20220409225935-user-invited-by.js
Normal file
17
server/migrations/20220409225935-user-invited-by.js
Normal file
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
return queryInterface.addColumn("users", "invitedById", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface) => {
|
||||
return queryInterface.removeColumn("users", "invitedById");
|
||||
}
|
||||
};
|
||||
@@ -57,6 +57,31 @@ export enum UserFlag {
|
||||
},
|
||||
],
|
||||
},
|
||||
withTeam: {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
withInvitedBy: {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "invitedBy",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
invited: {
|
||||
where: {
|
||||
lastActiveAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "users", modelName: "user" })
|
||||
@Fix
|
||||
@@ -141,11 +166,18 @@ class User extends ParanoidModel {
|
||||
// associations
|
||||
|
||||
@HasOne(() => User, "suspendedById")
|
||||
suspendedBy: User;
|
||||
suspendedBy: User | null;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
suspendedById: string;
|
||||
suspendedById: string | null;
|
||||
|
||||
@HasOne(() => User, "invitedById")
|
||||
invitedBy: User | null;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
invitedById: string | null;
|
||||
|
||||
@BelongsTo(() => Team)
|
||||
team: Team;
|
||||
|
||||
@@ -15,7 +15,7 @@ export default abstract class BaseTask<T> {
|
||||
* @param props Properties to be used by the task
|
||||
* @returns A promise that resolves once the job is placed on the task queue
|
||||
*/
|
||||
public static schedule<T>(props: T) {
|
||||
public static schedule<T>(props?: T) {
|
||||
// @ts-expect-error cannot create an instance of an abstract class, we wont
|
||||
const task = new this();
|
||||
return taskQueue.add(
|
||||
|
||||
37
server/queues/tasks/InviteReminderTask.test.ts
Normal file
37
server/queues/tasks/InviteReminderTask.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { subDays } from "date-fns";
|
||||
import InviteReminderEmail from "@server/emails/templates/InviteReminderEmail";
|
||||
import { buildInvite } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import InviteReminderTask from "./InviteReminderTask";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("InviteReminderTask", () => {
|
||||
it("should not destroy documents not deleted", async () => {
|
||||
const spy = jest.spyOn(InviteReminderEmail, "schedule");
|
||||
|
||||
// too old
|
||||
await buildInvite({
|
||||
createdAt: subDays(new Date(), 3.5),
|
||||
});
|
||||
|
||||
// too new
|
||||
await buildInvite({
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
// should send reminder
|
||||
await buildInvite({
|
||||
createdAt: subDays(new Date(), 2.5),
|
||||
});
|
||||
|
||||
const task = new InviteReminderTask();
|
||||
await task.perform();
|
||||
|
||||
// running twice to make sure the email is only sent once
|
||||
await task.perform();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
67
server/queues/tasks/InviteReminderTask.ts
Normal file
67
server/queues/tasks/InviteReminderTask.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { subDays } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import InviteReminderEmail from "@server/emails/templates/InviteReminderEmail";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import { User } from "@server/models";
|
||||
import { UserFlag } from "@server/models/User";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
type Props = undefined;
|
||||
|
||||
@APM.trace()
|
||||
export default class InviteReminderTask extends BaseTask<Props> {
|
||||
public async perform() {
|
||||
const users = await User.scope("invited").findAll({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
createdAt: {
|
||||
[Op.lt]: subDays(new Date(), 2),
|
||||
[Op.gt]: subDays(new Date(), 3),
|
||||
},
|
||||
},
|
||||
});
|
||||
const userIds = users.map((user) => user.id);
|
||||
|
||||
for (const userId of userIds) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const user = await User.scope("withTeam").findByPk(userId, {
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: User,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
const invitedBy = user?.invitedById
|
||||
? await User.findByPk(user?.invitedById, { transaction })
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
user &&
|
||||
invitedBy &&
|
||||
user.getFlag(UserFlag.InviteReminderSent) === 0
|
||||
) {
|
||||
await InviteReminderEmail.schedule({
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
actorName: invitedBy.name,
|
||||
actorEmail: invitedBy.email,
|
||||
teamName: user.team.name,
|
||||
teamUrl: user.team.url,
|
||||
});
|
||||
|
||||
user.incrementFlag(UserFlag.InviteReminderSent);
|
||||
await user.save({ transaction });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
attempts: 1,
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ router.post("users.resendInvite", auth(), async (ctx) => {
|
||||
});
|
||||
authorize(actor, "resendInvite", user);
|
||||
|
||||
if (user.getFlag(UserFlag.InviteReminderSent) > 2) {
|
||||
if (user.getFlag(UserFlag.InviteSent) > 2) {
|
||||
throw ValidationError("This invite has been sent too many times");
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ router.post("users.resendInvite", auth(), async (ctx) => {
|
||||
teamUrl: actor.team.url,
|
||||
});
|
||||
|
||||
user.incrementFlag(UserFlag.InviteReminderSent);
|
||||
user.incrementFlag(UserFlag.InviteSent);
|
||||
await user.save({ transaction });
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthenticationError } from "@server/errors";
|
||||
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
|
||||
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
|
||||
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
|
||||
import InviteReminderTask from "@server/queues/tasks/InviteReminderTask";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -19,6 +20,8 @@ router.post("utils.gc", async (ctx) => {
|
||||
|
||||
await CleanupDeletedTeamsTask.schedule({ limit });
|
||||
|
||||
await InviteReminderTask.schedule();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -161,12 +161,16 @@ export async function buildInvite(overrides: Partial<User> = {}) {
|
||||
overrides.teamId = team.id;
|
||||
}
|
||||
|
||||
const actor = await buildUser({ teamId: overrides.teamId });
|
||||
|
||||
count++;
|
||||
return User.create({
|
||||
email: `user${count}@example.com`,
|
||||
name: `User ${count}`,
|
||||
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||
invitedById: actor.id,
|
||||
...overrides,
|
||||
lastActiveAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user