feat: Add optional notification email when invite is accepted (#3718)

* feat: Add optional notification email when invite is accepted

* Refactor to use beforeSend
This commit is contained in:
Tom Moor
2022-07-02 15:40:40 +03:00
committed by GitHub
parent ee22a127f6
commit 863f22750f
14 changed files with 169 additions and 66 deletions

View File

@@ -44,6 +44,13 @@ function Notifications() {
"Receive a notification whenever a new collection is created"
),
},
{
event: "emails.invite_accepted",
title: t("Invite accepted"),
description: t(
"Receive a notification when someone you invited creates an account"
),
},
{
visible: isCloudHosted,
event: "emails.onboarding",

View File

@@ -1,4 +1,6 @@
import { Op } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import { DomainNotAllowedError, InviteRequiredError } from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models";
@@ -87,7 +89,7 @@ export default async function userCreator({
// A `user` record might exist in the form of an invite even if there is no
// existing authentication record that matches. In Outline an invite is a
// shell user record.
const invite = await User.findOne({
const invite = await User.scope(["withAuthentications", "withTeam"]).findOne({
where: {
// Email from auth providers may be capitalized and we should respect that
// however any existing invites will always be lowercased.
@@ -97,22 +99,12 @@ export default async function userCreator({
[Op.is]: null,
},
},
include: [
{
model: UserAuthentication,
as: "authentications",
required: false,
},
],
});
// We have an existing invite for his user, so we need to update it with our
// new details and link up the authentication method
if (invite && !invite.authentications.length) {
const transaction = await User.sequelize!.transaction();
let auth;
try {
const auth = await sequelize.transaction(async (transaction) => {
await invite.update(
{
name,
@@ -122,17 +114,23 @@ export default async function userCreator({
transaction,
}
);
auth = await invite.$create<UserAuthentication>(
return await invite.$create<UserAuthentication>(
"authentication",
authentication,
{
transaction,
}
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
});
const inviter = await invite.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: invite.name,
teamUrl: invite.team.url,
});
}
return {

View File

@@ -1,4 +1,5 @@
import mailer from "@server/emails/mailer";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/metrics";
import { taskQueue } from "@server/queues";
import { TaskPriority } from "@server/queues/tasks/BaseTask";
@@ -54,11 +55,19 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
* @returns A promise that resolves once the email has been successfully sent.
*/
public async send() {
const bsResponse = this.beforeSend
? await this.beforeSend(this.props)
: ({} as S);
const data = { ...this.props, ...bsResponse };
const templateName = this.constructor.name;
const bsResponse = await this.beforeSend?.(this.props);
if (bsResponse === false) {
Logger.info(
"email",
`Email ${templateName} not sent due to beforeSend hook`,
this.props
);
return;
}
const data = { ...this.props, ...(bsResponse ?? ({} as S)) };
try {
await mailer.sendMail({
@@ -116,10 +125,11 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
/**
* beforeSend hook allows async loading additional data that was not passed
* through the serialized worker props.
* through the serialized worker props. If false is returned then the email
* send is aborted.
*
* @param props Props in email constructor
* @returns A promise resolving to additional data
*/
protected beforeSend?(props: T): Promise<S>;
protected beforeSend?(props: T): Promise<S | false>;
}

View File

@@ -1,4 +1,3 @@
import invariant from "invariant";
import * as React from "react";
import env from "@server/env";
import { Collection } from "@server/models";
@@ -37,7 +36,10 @@ export default class CollectionNotificationEmail extends BaseEmail<
const collection = await Collection.scope("withUser").findByPk(
collectionId
);
invariant(collection, "Collection not found");
if (!collection) {
return false;
}
return { collection };
}

View File

@@ -1,4 +1,3 @@
import invariant from "invariant";
import * as React from "react";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
@@ -36,7 +35,10 @@ export default class DocumentNotificationEmail extends BaseEmail<
> {
protected async beforeSend({ documentId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
invariant(document, "Document not found");
if (!document) {
return false;
}
return { document };
}

View File

@@ -0,0 +1,82 @@
import * as React from "react";
import { NotificationSetting } from "@server/models";
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;
inviterId: string;
invitedName: string;
teamUrl: string;
};
type BeforeSendProps = {
unsubscribeUrl: string;
};
/**
* Email sent to a user when someone they invited successfully signs up.
*/
export default class InviteAcceptedEmail extends BaseEmail<Props> {
protected async beforeSend({ inviterId }: Props) {
const notificationSetting = await NotificationSetting.findOne({
where: {
userId: inviterId,
event: "emails.invite_accepted",
},
});
if (!notificationSetting) {
return false;
}
return { unsubscribeUrl: notificationSetting.unsubscribeUrl };
}
protected subject({ invitedName }: Props) {
return `${invitedName} has joined your Outline team`;
}
protected preview({ invitedName }: Props) {
return `Great news, ${invitedName}, accepted your invitation`;
}
protected renderAsText({ invitedName, teamUrl }: Props): string {
return `
Great news, ${invitedName} just accepted your invitation and has created an account. You can now start collaborating on documents.
Open Outline: ${teamUrl}
`;
}
protected render({
invitedName,
teamUrl,
unsubscribeUrl,
}: Props & BeforeSendProps) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>{invitedName} has joined your team</Heading>
<p>
Great news, {invitedName} just accepted your invitation and has
created an account. You can now start collaborating on documents.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Open Outline</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}

View File

@@ -32,7 +32,7 @@ Welcome to Outline!
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.
To get started, head to the home screen and try creating a collection to help document your processes, create playbooks, or plan your teams work.
You can also import existing Markdown documents by dragging and dropping them to your collections.
@@ -49,9 +49,9 @@ ${teamUrl}/home
<Heading>Welcome to Outline!</Heading>
<p>Outline is a place for your team to build and share knowledge.</p>
<p>
To get started, head to your dashboard and try creating a collection
to help document your workflow, create playbooks or help with team
onboarding.
To get started, head to the home screen and try creating a
collection to help document your processes, create playbooks, or
plan your teams work.
</p>
<p>
You can also import existing Markdown documents by dragging and
@@ -59,7 +59,7 @@ ${teamUrl}/home
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
<Button href={`${teamUrl}/home`}>Open Outline</Button>
</p>
</Body>

View File

@@ -38,6 +38,7 @@ class NotificationSetting extends Model {
"documents.publish",
"documents.update",
"collections.create",
"emails.invite_accepted",
"emails.onboarding",
"emails.features",
],
@@ -48,12 +49,13 @@ class NotificationSetting extends Model {
// getters
get unsubscribeUrl() {
const token = NotificationSetting.getUnsubscribeToken(this.userId);
return `${env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`;
return `${env.URL}/api/notificationSettings.unsubscribe?token=${this.unsubscribeToken}&id=${this.id}`;
}
get unsubscribeToken() {
return NotificationSetting.getUnsubscribeToken(this.userId);
const hash = crypto.createHash("sha256");
hash.update(`${this.userId}-${env.SECRET_KEY}`);
return hash.digest("hex");
}
// associations
@@ -71,12 +73,6 @@ class NotificationSetting extends Model {
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
static getUnsubscribeToken = (userId: string) => {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${env.SECRET_KEY}`);
return hash.digest("hex");
};
}
export default NotificationSetting;

View File

@@ -7,7 +7,6 @@ import {
Column,
IsIP,
IsEmail,
HasOne,
Default,
IsIn,
BeforeDestroy,
@@ -164,15 +163,14 @@ class User extends ParanoidModel {
}
// associations
@HasOne(() => User, "suspendedById")
@BelongsTo(() => User, "suspendedById")
suspendedBy: User | null;
@ForeignKey(() => User)
@Column(DataType.UUID)
suspendedById: string | null;
@HasOne(() => User, "invitedById")
@BelongsTo(() => User, "invitedById")
invitedBy: User | null;
@ForeignKey(() => User)
@@ -292,11 +290,12 @@ class User extends ParanoidModel {
};
updateSignedIn = (ip: string) => {
this.lastSignedInAt = new Date();
const now = new Date();
this.lastActiveAt = now;
this.lastActiveIp = ip;
this.lastSignedInAt = now;
this.lastSignedInIp = ip;
return this.save({
hooks: false,
});
return this.save({ hooks: false });
};
/**
@@ -521,6 +520,14 @@ class User extends ParanoidModel {
},
transaction: options.transaction,
}),
NotificationSetting.findOrCreate({
where: {
userId: model.id,
teamId: model.teamId,
event: "emails.invite_accepted",
},
transaction: options.transaction,
}),
]);
};

View File

@@ -2,6 +2,7 @@ import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import { parseDomain } from "@shared/utils/domains";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
@@ -145,11 +146,17 @@ router.get("email.callback", async (ctx) => {
to: user.email,
teamUrl: user.team.url,
});
}
await user.update({
lastActiveAt: new Date(),
const inviter = await user.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
});
}
}
// set cookies on response and redirect to team subdomain
await signIn(ctx, user, user.team, "email", false, false);

View File

@@ -139,10 +139,6 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
throw InviteRequiredError();
}
await user.update({
lastActiveAt: new Date(),
});
result = {
user,
team,

View File

@@ -40,7 +40,8 @@ export async function signIn(
}
// update the database when the user last signed in
user.updateSignedIn(ctx.request.ip);
await user.updateSignedIn(ctx.request.ip);
// don't await event creation for a faster sign-in
Event.create({
name: "users.signin",

View File

@@ -82,14 +82,7 @@ export async function getUserForEmailSigninToken(token: string): Promise<User> {
}
}
const user = await User.findByPk(payload.id, {
include: [
{
model: Team,
required: true,
},
],
});
const user = await User.scope("withTeam").findByPk(payload.id);
invariant(user, "User not found");
try {

View File

@@ -641,6 +641,8 @@
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
"Collection created": "Collection created",
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
"Invite accepted": "Invite accepted",
"Receive a notification when someone you invited creates an account": "Receive a notification when someone you invited creates an account",
"Getting started": "Getting started",
"Tips on getting started with Outlines features and functionality": "Tips on getting started with Outlines features and functionality",
"New features": "New features",