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:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
82
server/emails/templates/InviteAcceptedEmail.tsx
Normal file
82
server/emails/templates/InviteAcceptedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -139,10 +139,6 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
throw InviteRequiredError();
|
||||
}
|
||||
|
||||
await user.update({
|
||||
lastActiveAt: new Date(),
|
||||
});
|
||||
|
||||
result = {
|
||||
user,
|
||||
team,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 Outline’s features and functionality": "Tips on getting started with Outline’s features and functionality",
|
||||
"New features": "New features",
|
||||
|
||||
Reference in New Issue
Block a user