From 863f22750ffb86fec628901a8380d285e5c12ab3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 2 Jul 2022 15:40:40 +0300 Subject: [PATCH] feat: Add optional notification email when invite is accepted (#3718) * feat: Add optional notification email when invite is accepted * Refactor to use beforeSend --- app/scenes/Settings/Notifications.tsx | 7 ++ server/commands/userCreator.ts | 32 ++++---- server/emails/templates/BaseEmail.tsx | 22 +++-- .../templates/CollectionNotificationEmail.tsx | 6 +- .../templates/DocumentNotificationEmail.tsx | 6 +- .../emails/templates/InviteAcceptedEmail.tsx | 82 +++++++++++++++++++ server/emails/templates/WelcomeEmail.tsx | 10 +-- server/models/NotificationSetting.ts | 14 ++-- server/models/User.ts | 23 ++++-- server/routes/auth/providers/email.ts | 15 +++- server/routes/auth/providers/google.ts | 4 - server/utils/authentication.ts | 3 +- server/utils/jwt.ts | 9 +- shared/i18n/locales/en_US/translation.json | 2 + 14 files changed, 169 insertions(+), 66 deletions(-) create mode 100644 server/emails/templates/InviteAcceptedEmail.tsx diff --git a/app/scenes/Settings/Notifications.tsx b/app/scenes/Settings/Notifications.tsx index 3aa155e07..afa738c11 100644 --- a/app/scenes/Settings/Notifications.tsx +++ b/app/scenes/Settings/Notifications.tsx @@ -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", diff --git a/server/commands/userCreator.ts b/server/commands/userCreator.ts index 569e165bc..bcf00a375 100644 --- a/server/commands/userCreator.ts +++ b/server/commands/userCreator.ts @@ -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( + return await invite.$create( "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 { diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx index c1dcf5fe4..b6bded38e 100644 --- a/server/emails/templates/BaseEmail.tsx +++ b/server/emails/templates/BaseEmail.tsx @@ -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 { * @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 { /** * 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; + protected beforeSend?(props: T): Promise; } diff --git a/server/emails/templates/CollectionNotificationEmail.tsx b/server/emails/templates/CollectionNotificationEmail.tsx index b5701d5b4..7011b6d01 100644 --- a/server/emails/templates/CollectionNotificationEmail.tsx +++ b/server/emails/templates/CollectionNotificationEmail.tsx @@ -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 }; } diff --git a/server/emails/templates/DocumentNotificationEmail.tsx b/server/emails/templates/DocumentNotificationEmail.tsx index 000fd4ed9..f7c7a54f4 100644 --- a/server/emails/templates/DocumentNotificationEmail.tsx +++ b/server/emails/templates/DocumentNotificationEmail.tsx @@ -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 }; } diff --git a/server/emails/templates/InviteAcceptedEmail.tsx b/server/emails/templates/InviteAcceptedEmail.tsx new file mode 100644 index 000000000..c5c1d1e54 --- /dev/null +++ b/server/emails/templates/InviteAcceptedEmail.tsx @@ -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 { + 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 ( + +
+ + + {invitedName} has joined your team +

+ Great news, {invitedName} just accepted your invitation and has + created an account. You can now start collaborating on documents. +

+ +

+ +

+ + +