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,
|
service: null,
|
||||||
isAdmin: invite.role === "admin",
|
isAdmin: invite.role === "admin",
|
||||||
isViewer: invite.role === "viewer",
|
isViewer: invite.role === "viewer",
|
||||||
|
invitedById: user.id,
|
||||||
flags: {
|
flags: {
|
||||||
[UserFlag.InviteSent]: 1,
|
[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");
|
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" })
|
@Table({ tableName: "users", modelName: "user" })
|
||||||
@Fix
|
@Fix
|
||||||
@@ -141,11 +166,18 @@ class User extends ParanoidModel {
|
|||||||
// associations
|
// associations
|
||||||
|
|
||||||
@HasOne(() => User, "suspendedById")
|
@HasOne(() => User, "suspendedById")
|
||||||
suspendedBy: User;
|
suspendedBy: User | null;
|
||||||
|
|
||||||
@ForeignKey(() => User)
|
@ForeignKey(() => User)
|
||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
suspendedById: string;
|
suspendedById: string | null;
|
||||||
|
|
||||||
|
@HasOne(() => User, "invitedById")
|
||||||
|
invitedBy: User | null;
|
||||||
|
|
||||||
|
@ForeignKey(() => User)
|
||||||
|
@Column(DataType.UUID)
|
||||||
|
invitedById: string | null;
|
||||||
|
|
||||||
@BelongsTo(() => Team)
|
@BelongsTo(() => Team)
|
||||||
team: Team;
|
team: Team;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default abstract class BaseTask<T> {
|
|||||||
* @param props Properties to be used by the task
|
* @param props Properties to be used by the task
|
||||||
* @returns A promise that resolves once the job is placed on the task queue
|
* @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
|
// @ts-expect-error cannot create an instance of an abstract class, we wont
|
||||||
const task = new this();
|
const task = new this();
|
||||||
return taskQueue.add(
|
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);
|
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");
|
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,
|
teamUrl: actor.team.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
user.incrementFlag(UserFlag.InviteReminderSent);
|
user.incrementFlag(UserFlag.InviteSent);
|
||||||
await user.save({ transaction });
|
await user.save({ transaction });
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AuthenticationError } from "@server/errors";
|
|||||||
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
|
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
|
||||||
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
|
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
|
||||||
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
|
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
|
||||||
|
import InviteReminderTask from "@server/queues/tasks/InviteReminderTask";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ router.post("utils.gc", async (ctx) => {
|
|||||||
|
|
||||||
await CleanupDeletedTeamsTask.schedule({ limit });
|
await CleanupDeletedTeamsTask.schedule({ limit });
|
||||||
|
|
||||||
|
await InviteReminderTask.schedule();
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -161,12 +161,16 @@ export async function buildInvite(overrides: Partial<User> = {}) {
|
|||||||
overrides.teamId = team.id;
|
overrides.teamId = team.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actor = await buildUser({ teamId: overrides.teamId });
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
return User.create({
|
return User.create({
|
||||||
email: `user${count}@example.com`,
|
email: `user${count}@example.com`,
|
||||||
name: `User ${count}`,
|
name: `User ${count}`,
|
||||||
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
createdAt: new Date("2018-01-01T00:00:00.000Z"),
|
||||||
|
invitedById: actor.id,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
lastActiveAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user