chore: Email + mailer refactor (#3342)

* Huge email refactor

* fix: One rename too many

* comments
This commit is contained in:
Tom Moor
2022-04-07 16:50:04 -07:00
committed by GitHub
parent 15375bf199
commit 5c24f9e1d5
39 changed files with 879 additions and 870 deletions

View File

@@ -1,67 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Mailer #welcome 1`] = `
Object {
"from": "hello@example.com",
"html": "
<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Strict//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\\">
<html
dir=\\"ltr\\"
xmlns=\\"http://www.w3.org/1999/xhtml\\"
xmlns:v=\\"urn:schemas-microsoft-com:vml\\"
xmlns:o=\\"urn:schemas-microsoft-com:office:office\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=utf-8\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"IE=edge\\" />
<meta name=\\"viewport\\" content=\\"width=device-width\\"/>
<title>Welcome to Outline</title>
<style type=\\"text/css\\">
#__bodyTable__{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;font-size:16px;line-height:1.5}
#__bodyTable__ {
margin: 0;
padding: 0;
width: 100% !important;
}
</style>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
</head>
<body bgcolor=\\"#FFFFFF\\" width=\\"100%\\" style=\\"-webkit-font-smoothing: antialiased; width:100% !important; background:#FFFFFF;-webkit-text-size-adjust:none; margin:0; padding:0; min-width:100%; direction: ltr;\\">
<table bgcolor=\\"#FFFFFF\\" id=\\"__bodyTable__\\" width=\\"100%\\" style=\\"-webkit-font-smoothing: antialiased; width:100% !important; background:#FFFFFF;-webkit-text-size-adjust:none; margin:0; padding:0; min-width:100%\\">
<tr>
<td align=\\"center\\">
<span style=\\"display: none !important; color: #FFFFFF; margin:0; padding:0; font-size:1px; line-height:1px;\\">Outline is a place for your team to build and share knowledge.</span>
<table width=\\"550\\" padding=\\"40\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td align=\\"left\\"><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"40px\\" style=\\"line-height:40px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table><img alt=\\"Outline\\" src=\\"http://localhost:3000/email/header-logo.png\\" height=\\"48\\" width=\\"48\\"/></td></tr></tbody></table><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"10px\\" style=\\"line-height:10px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table><p><span style=\\"font-weight:500;font-size:18px\\">Welcome to Outline!</span></p><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.</p><p>You can also import existing Markdown documents by dragging and dropping them to your collections.</p><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"10px\\" style=\\"line-height:10px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table><p><a href=\\"http://example.com/home\\" style=\\"display:inline-block;padding:10px 20px;color:#FFFFFF;background:#000000;border-radius:4px;font-weight:500;text-decoration:none;cursor:pointer\\">View my dashboard</a></p><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"40px\\" style=\\"line-height:40px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table></td></tr></tbody></table><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td style=\\"padding:20px 0;border-top:1px solid #E8EBED;color:#9BA6B2;font-size:14px\\"><a href=\\"http://localhost:3000\\" style=\\"color:#9BA6B2;font-weight:500;text-decoration:none;margin-right:10px\\">Outline</a><a href=\\"https://twitter.com/getoutline\\" style=\\"color:#9BA6B2;text-decoration:none;margin:0 10px\\">Twitter</a></td></tr></tbody></table></td></tr></tbody></table>
</td>
</tr>
</table>
</body>
</html>
",
"replyTo": "hello@example.com",
"subject": "Welcome to Outline",
"text": "
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.
You can also import existing Markdown documents by dragging and dropping them to your collections.
http://example.com/home
",
"to": "user@example.com",
}
`;

View File

@@ -1,6 +1,6 @@
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import Collection from "@server/models/Collection";
import UserAuthentication from "@server/models/UserAuthentication";
import EmailTask from "@server/queues/tasks/EmailTask";
import { buildUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
import accountProvisioner from "./accountProvisioner";
@@ -13,7 +13,7 @@ describe("accountProvisioner", () => {
const ip = "127.0.0.1";
it("should create a new user and team", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
ip,
user: {
@@ -55,7 +55,7 @@ describe("accountProvisioner", () => {
});
it("should update exising user and authentication", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const existingTeam = await buildTeam();
const providers = await existingTeam.$get("authenticationProviders");
const authenticationProvider = providers[0];
@@ -149,7 +149,7 @@ describe("accountProvisioner", () => {
});
it("should create a new user in an existing team", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const team = await buildTeam();
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import { UniqueConstraintError } from "sequelize";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import {
AuthenticationError,
EmailAuthenticationRequiredError,
@@ -7,7 +8,6 @@ import {
} from "@server/errors";
import { APM } from "@server/logging/tracing";
import { Collection, Team, User } from "@server/models";
import EmailTask from "@server/queues/tasks/EmailTask";
import teamCreator from "./teamCreator";
import userCreator from "./userCreator";
@@ -89,12 +89,9 @@ async function accountProvisioner({
const { isNewUser, user } = result;
if (isNewUser) {
await EmailTask.schedule({
type: "welcome",
options: {
to: user.email,
teamUrl: team.url,
},
await WelcomeEmail.schedule({
to: user.email,
teamUrl: team.url,
});
}

View File

@@ -1,9 +1,9 @@
import invariant from "invariant";
import { uniqBy } from "lodash";
import { Role } from "@shared/types";
import InviteEmail from "@server/emails/templates/InviteEmail";
import Logger from "@server/logging/logger";
import { User, Event, Team } from "@server/models";
import EmailTask from "@server/queues/tasks/EmailTask";
type Invite = {
name: string;
@@ -75,16 +75,13 @@ export default async function userInviter({
ip,
});
await EmailTask.schedule({
type: "invite",
options: {
to: invite.email,
name: invite.name,
actorName: user.name,
actorEmail: user.email,
teamName: team.name,
teamUrl: team.url,
},
await InviteEmail.schedule({
to: invite.email,
name: invite.name,
actorName: user.name,
actorEmail: user.email,
teamName: team.name,
teamUrl: team.url,
});
if (process.env.NODE_ENV === "development") {

View File

@@ -1,53 +0,0 @@
import * as React from "react";
import { Collection } from "@server/models";
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";
export type Props = {
collection: Collection;
eventName: string;
unsubscribeUrl: string;
};
export const collectionNotificationEmailText = ({
collection,
eventName = "created",
}: Props) => `
${collection.name}
${collection.user.name} ${eventName} the collection "${collection.name}"
Open Collection: ${process.env.URL}${collection.url}
`;
export const CollectionNotificationEmail = ({
collection,
eventName = "created",
unsubscribeUrl,
}: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>{collection.name}</Heading>
<p>
{collection.user.name} {eventName} the collection "{collection.name}".
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}${collection.url}`}>
Open Collection
</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
};

View File

@@ -1,66 +0,0 @@
import * as React from "react";
import { Document } from "@server/models";
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";
export type Props = {
document: Document;
actorName: string;
collectionName: string;
eventName: string;
teamUrl: string;
unsubscribeUrl: string;
};
export const documentNotificationEmailText = ({
actorName,
teamUrl,
document,
collectionName,
eventName = "published",
}: Props) => `
"${document.title}" ${eventName}
${actorName} ${eventName} the document "${document.title}", in the ${collectionName} collection.
Open Document: ${teamUrl}${document.url}
`;
export const DocumentNotificationEmail = ({
document,
actorName,
collectionName,
eventName = "published",
teamUrl,
unsubscribeUrl,
}: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>
"{document.title}" {eventName}
</Heading>
<p>
{actorName} {eventName} the document "{document.title}", in the{" "}
{collectionName} collection.
</p>
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}${document.url}`}>Open Document</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
};

View File

@@ -1,44 +0,0 @@
import * as React from "react";
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";
export const exportEmailFailureText = `
Your Data Export
Sorry, your requested data export has failed, please visit the admin
section to try again if the problem persists please contact support.
`;
export const ExportFailureEmail = ({ teamUrl }: { teamUrl: string }) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Sorry, your requested data export has failed, please visit the{" "}
<a
href={`${teamUrl}/settings/export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
. to try again if the problem persists please contact support.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/settings/export`}>Go to export</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -1,52 +0,0 @@
import * as React from "react";
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";
export const exportEmailSuccessText = `
Your Data Export
Your requested data export is complete, the exported files are also available in the admin section.
`;
export const ExportSuccessEmail = ({
id,
teamUrl,
}: {
id: string;
teamUrl: string;
}) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Your requested data export is complete, the exported files are also
available in the{" "}
<a
href={`${teamUrl}/settings/export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/api/fileOperations.redirect?id=${id}`}>
Download
</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -1,56 +0,0 @@
import * as React from "react";
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";
export type Props = {
name: string;
actorName: string;
actorEmail: string;
teamName: string;
teamUrl: string;
};
export const inviteEmailText = ({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props) => `
Join ${teamName} on Outline
${actorName} (${actorEmail}) has invited you to join Outline, a place for your team to build and share knowledge.
Join now: ${teamUrl}
`;
export const InviteEmail = ({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Join {teamName} on Outline</Heading>
<p>
{actorName} ({actorEmail}) has invited you to join Outline, a place
for your team to build and share knowledge.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -1,50 +0,0 @@
import * as React from "react";
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";
export type Props = {
token: string;
teamUrl: string;
};
export const signinEmailText = ({ token, teamUrl }: Props) => `
Use the link below to signin to Outline:
${process.env.URL}/auth/email.callback?token=${token}
If your magic link expired you can request a new one from your teams
signin page at: ${teamUrl}
`;
export const SigninEmail = ({ token, teamUrl }: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Magic Sign-in Link</Heading>
<p>Click the button below to sign in to Outline.</p>
<EmptySpace height={10} />
<p>
<Button
href={`${process.env.URL}/auth/email.callback?token=${token}`}
>
Sign In
</Button>
</p>
<EmptySpace height={10} />
<p>
If your magic link expired you can request a new one from your teams
sign-in page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -1,52 +0,0 @@
import * as React from "react";
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";
export type Props = {
teamUrl: string;
};
export const welcomeEmailText = ({ teamUrl }: Props) => `
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.
You can also import existing Markdown documents by dragging and dropping them to your collections.
${teamUrl}/home
`;
export const WelcomeEmail = ({ teamUrl }: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<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.
</p>
<p>
You can also import existing Markdown documents by dragging and
dropping them to your collections.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -1,44 +0,0 @@
import Koa from "koa";
import Router from "koa-router";
import { NotFoundError } from "../errors";
import { Mailer } from "../mailer";
const emailPreviews = new Koa();
const router = new Router();
router.get("/:type/:format", async (ctx) => {
let mailerOutput;
const mailer = new Mailer();
mailer.transporter = {
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'data' implicitly has an 'any' type.
sendMail: (data) => (mailerOutput = data),
};
switch (ctx.params.type) {
// case 'emailWithProperties':
// mailer.emailWithProperties('user@example.com', {...properties});
// break;
default:
if (Object.getOwnPropertyNames(mailer).includes(ctx.params.type)) {
mailer[ctx.params.type]("user@example.com");
} else {
throw NotFoundError("Email template could not be found");
}
}
if (!mailerOutput) {
return;
}
if (ctx.params.format === "text") {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'never'.
ctx.body = mailerOutput.text;
} else {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'html' does not exist on type 'never'.
ctx.body = mailerOutput.html;
}
});
emailPreviews.use(router.routes());
export default emailPreviews;

131
server/emails/mailer.tsx Normal file
View File

@@ -0,0 +1,131 @@
import nodemailer, { Transporter } from "nodemailer";
import Oy from "oy-vey";
import * as React from "react";
import Logger from "@server/logging/logger";
import { baseStyles } from "./templates/components/EmailLayout";
const useTestEmailService =
process.env.NODE_ENV === "development" && !process.env.SMTP_USERNAME;
type SendMailOptions = {
to: string;
subject: string;
previewText?: string;
text: string;
component: React.ReactNode;
headCSS?: string;
};
/**
* Mailer class to send emails.
*/
export class Mailer {
transporter: Transporter | undefined;
constructor() {
if (process.env.SMTP_HOST) {
this.transporter = nodemailer.createTransport(this.getOptions());
}
if (useTestEmailService) {
Logger.info(
"email",
"SMTP_USERNAME not provided, generating test account…"
);
this.getTestTransportOptions().then((options) => {
if (!options) {
Logger.info(
"email",
"Couldn't generate a test account with ethereal.email at this time emails will not be sent."
);
}
this.transporter = nodemailer.createTransport(options);
});
}
}
sendMail = async (data: SendMailOptions): Promise<void> => {
const { transporter } = this;
if (!transporter) {
Logger.info(
"email",
`Attempted to send email "${data.subject}" to ${data.to} but no transport configured.`
);
return;
}
const html = Oy.renderTemplate(data.component, {
title: data.subject,
headCSS: [baseStyles, data.headCSS].join(" "),
previewText: data.previewText,
});
try {
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
const info = await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
to: data.to,
subject: data.subject,
html,
text: data.text,
});
if (useTestEmailService) {
Logger.info(
"email",
`Preview Url: ${nodemailer.getTestMessageUrl(info)}`
);
}
} catch (err) {
Logger.error(`Error sending email to ${data.to}`, err);
throw err; // Re-throw for queue to re-try
}
};
private getOptions() {
return {
host: process.env.SMTP_HOST || "",
port: parseInt(process.env.SMTP_PORT || "", 10),
secure:
"SMTP_SECURE" in process.env
? process.env.SMTP_SECURE === "true"
: process.env.NODE_ENV === "production",
auth: process.env.SMTP_USERNAME
? {
user: process.env.SMTP_USERNAME || "",
pass: process.env.SMTP_PASSWORD,
}
: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? {
ciphers: process.env.SMTP_TLS_CIPHERS,
}
: undefined,
};
}
private async getTestTransportOptions() {
try {
const testAccount = await nodemailer.createTestAccount();
return {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
} catch (err) {
return undefined;
}
}
}
const mailer = new Mailer();
export default mailer;

View File

@@ -0,0 +1,107 @@
import mailer from "@server/emails/mailer";
import { taskQueue } from "@server/queues";
import { TaskPriority } from "@server/queues/tasks/BaseTask";
interface EmailProps {
to: string;
}
export default abstract class BaseEmail<T extends EmailProps, S = any> {
private props: T;
/**
* Schedule this email type to be sent asyncronously by a worker.
*
* @param props Properties to be used in the email template
* @returns A promise that resolves once the email is placed on the task queue
*/
public static schedule<T>(props: T) {
// Ideally we'd use EmailTask.schedule here but importing creates a circular
// dependency so we're pushing onto the task queue in the expected format
return taskQueue.add(
{
name: "EmailTask",
props: {
templateName: this.name,
props,
},
},
{
priority: TaskPriority.Normal,
attempts: 5,
backoff: {
type: "exponential",
delay: 60 * 1000,
},
}
);
}
constructor(props: T) {
this.props = props;
}
/**
* Send this email now.
*
* @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 };
return mailer.sendMail({
to: this.props.to,
subject: this.subject(data),
previewText: this.preview(data),
component: this.render(data),
text: this.renderAsText(data),
});
}
/**
* Returns the subject of the email.
*
* @param props Props in email constructor
* @returns The email subject as a string
*/
protected abstract subject(props: S & T): string;
/**
* Returns the preview text of the email, this is the text that will be shown
* in email client list views.
*
* @param props Props in email constructor
* @returns The preview text as a string
*/
protected abstract preview(props: S & T): string;
/**
* Returns a plain-text version of the email, this is the text that will be
* shown if the email client does not support or want HTML.
*
* @param props Props in email constructor
* @returns The plain text email as a string
*/
protected abstract renderAsText(props: S & T): string;
/**
* Returns a React element that will be rendered on the server to produce the
* HTML version of the email.
*
* @param props Props in email constructor
* @returns A JSX element
*/
protected abstract render(props: S & T): JSX.Element;
/**
* beforeSend hook allows async loading additional data that was not passed
* through the serialized worker props.
*
* @param props Props in email constructor
* @returns A promise resolving to additional data
*/
protected beforeSend?(props: T): Promise<S>;
}

View File

@@ -0,0 +1,88 @@
import invariant from "invariant";
import * as React from "react";
import { Collection } 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 InputProps = {
to: string;
eventName: string;
collectionId: string;
unsubscribeUrl: string;
};
type BeforeSend = {
collection: Collection;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they have enabled notifications of new collection
* creation.
*/
export default class CollectionNotificationEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ collectionId }: Props) {
const collection = await Collection.scope("withUser").findByPk(
collectionId
);
invariant(collection, "Collection not found");
return { collection };
}
protected subject({ collection, eventName }: Props) {
return `${collection.name}${eventName}`;
}
protected preview({ collection, eventName }: Props) {
return `${collection.user.name} ${eventName} a collection`;
}
protected renderAsText({ collection, eventName = "created" }: Props) {
return `
${collection.name}
${collection.user.name} ${eventName} the collection "${collection.name}"
Open Collection: ${process.env.URL}${collection.url}
`;
}
protected render({
collection,
eventName = "created",
unsubscribeUrl,
}: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>{collection.name}</Heading>
<p>
{collection.user.name} {eventName} the collection "{collection.name}
".
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}${collection.url}`}>
Open Collection
</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,100 @@
import invariant from "invariant";
import * as React from "react";
import { Document } 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 InputProps = {
to: string;
documentId: string;
actorName: string;
collectionName: string;
eventName: string;
teamUrl: string;
unsubscribeUrl: string;
};
type BeforeSend = {
document: Document;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they have enabled document notifications, the event
* may be published or updated.
*/
export default class DocumentNotificationEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
invariant(document, "Document not found");
return { document };
}
protected subject({ document, eventName }: Props) {
return `${document.title}${eventName}`;
}
protected preview({ actorName, eventName }: Props): string {
return `${actorName} ${eventName} a new document`;
}
protected renderAsText({
actorName,
teamUrl,
document,
collectionName,
eventName = "published",
}: Props): string {
return `
"${document.title}" ${eventName}
${actorName} ${eventName} the document "${document.title}", in the ${collectionName} collection.
Open Document: ${teamUrl}${document.url}
`;
}
protected render({
document,
actorName,
collectionName,
eventName = "published",
teamUrl,
unsubscribeUrl,
}: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>
"{document.title}" {eventName}
</Heading>
<p>
{actorName} {eventName} the document "{document.title}", in the{" "}
{collectionName} collection.
</p>
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}${document.url}`}>Open Document</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,63 @@
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;
teamUrl: string;
};
/**
* Email sent to a user when their data export has failed for some reason.
*/
export default class ExportFailureEmail extends BaseEmail<Props> {
protected subject() {
return "Your requested export";
}
protected preview() {
return "Sorry, your requested data export has failed";
}
protected renderAsText() {
return `
Your Data Export
Sorry, your requested data export has failed, please visit the admin
section to try again if the problem persists please contact support.
`;
}
protected render({ teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Sorry, your requested data export has failed, please visit the{" "}
<a
href={`${teamUrl}/settings/export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
. to try again if the problem persists please contact support.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/settings/export`}>Go to export</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,69 @@
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;
id: string;
teamUrl: string;
};
/**
* Email sent to a user when their data export has completed and is available
* for download in the settings section.
*/
export default class ExportSuccessEmail extends BaseEmail<Props> {
protected subject() {
return "Your requested export";
}
protected preview() {
return "Here's your request data export from Outline";
}
protected renderAsText() {
return `
Your Data Export
Your requested data export is complete, the exported files are also available in the admin section.
`;
}
protected render({ id, teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Your requested data export is complete, the exported files are also
available in the{" "}
<a
href={`${teamUrl}/settings/export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/api/fileOperations.redirect?id=${id}`}>
Download
</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,68 @@
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.
*/
export default class InviteEmail extends BaseEmail<Props> {
protected subject({ actorName, teamName }: Props) {
return `${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 `
Join ${teamName} on Outline
${actorName} (${actorEmail}) has invited you to join Outline, a place for your team to build and share knowledge.
Join now: ${teamUrl}
`;
}
protected render({ teamName, actorName, actorEmail, teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Join {teamName} on Outline</Heading>
<p>
{actorName} ({actorEmail}) has invited you to join Outline, a place
for your team to build and share knowledge.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import logger from "@server/logging/logger";
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;
token: string;
teamUrl: string;
};
/**
* Email sent to a user when they request a magic sign-in link.
*/
export default class SigninEmail extends BaseEmail<Props> {
protected subject() {
return "Magic signin link";
}
protected preview(): string {
return "Heres your link to signin to Outline.";
}
protected renderAsText({ token, teamUrl }: Props): string {
return `
Use the link below to signin to Outline:
${this.signinLink(token)}
If your magic link expired you can request a new one from your teams
signin page at: ${teamUrl}
`;
}
protected render({ token, teamUrl }: Props) {
if (process.env.NODE_ENV === "development") {
logger.debug("email", `Sign-In link: ${this.signinLink(token)}`);
}
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Magic Sign-in Link</Heading>
<p>Click the button below to sign in to Outline.</p>
<EmptySpace height={10} />
<p>
<Button href={this.signinLink(token)}>Sign In</Button>
</p>
<EmptySpace height={10} />
<p>
If your magic link expired you can request a new one from your
teams sign-in page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
private signinLink(token: string): string {
return `${process.env.URL}/auth/email.callback?token=${token}`;
}
}

View 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;
teamUrl: string;
};
/**
* Email sent to a user when their account has just been created, or they signed
* in for the first time from an invite.
*/
export default class WelcomeEmail extends BaseEmail<Props> {
protected subject() {
return "Welcome to Outline";
}
protected preview() {
return "Outline is a place for your team to build and share knowledge.";
}
protected renderAsText({ teamUrl }: Props) {
return `
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.
You can also import existing Markdown documents by dragging and dropping them to your collections.
${teamUrl}/home
`;
}
protected render({ teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<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.
</p>
<p>
You can also import existing Markdown documents by dragging and
dropping them to your collections.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,16 @@
import { requireDirectory } from "@server/utils/fs";
const emails = {};
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'default' does not exist on type 'unknown'
const { default: Email } = module;
if (id === "index") {
return;
}
emails[id] = Email;
});
export default emails;

View File

@@ -1,23 +0,0 @@
import mailer from "./mailer";
describe("Mailer", () => {
const fakeMailer = mailer;
let sendMailOutput: any;
beforeEach(() => {
process.env.URL = "http://localhost:3000";
process.env.SMTP_FROM_EMAIL = "hello@example.com";
jest.resetModules();
fakeMailer.transporter = {
sendMail: (output: any) => (sendMailOutput = output),
};
});
test("#welcome", () => {
fakeMailer.welcome({
to: "user@example.com",
teamUrl: "http://example.com",
});
expect(sendMailOutput).toMatchSnapshot();
});
});

View File

@@ -1,269 +0,0 @@
import invariant from "invariant";
import nodemailer from "nodemailer";
import Oy from "oy-vey";
import * as React from "react";
import { Collection, Document } from "@server/models";
import {
CollectionNotificationEmail,
collectionNotificationEmailText,
} from "./emails/CollectionNotificationEmail";
import {
DocumentNotificationEmail,
documentNotificationEmailText,
} from "./emails/DocumentNotificationEmail";
import {
ExportFailureEmail,
exportEmailFailureText,
} from "./emails/ExportFailureEmail";
import {
ExportSuccessEmail,
exportEmailSuccessText,
} from "./emails/ExportSuccessEmail";
import {
Props as InviteEmailT,
InviteEmail,
inviteEmailText,
} from "./emails/InviteEmail";
import { SigninEmail, signinEmailText } from "./emails/SigninEmail";
import { WelcomeEmail, welcomeEmailText } from "./emails/WelcomeEmail";
import { baseStyles } from "./emails/components/EmailLayout";
import Logger from "./logging/logger";
const useTestEmailService =
process.env.NODE_ENV === "development" && !process.env.SMTP_USERNAME;
export type EmailTypes =
| "welcome"
| "export"
| "invite"
| "signin"
| "exportFailure"
| "exportSuccess";
export type EmailSendOptions = {
to: string;
properties?: any;
title: string;
previewText?: string;
text: string;
html: React.ReactNode;
headCSS?: string;
};
/**
* Mailer
*
* Mailer class to contruct and send emails.
*
* To preview emails, add a new preview to `emails/index.js` if they
* require additional data (properties). Otherwise preview will work automatically.
*
* HTML: http://localhost:3000/email/:email_type/html
* TEXT: http://localhost:3000/email/:email_type/text
*/
export class Mailer {
transporter: any | null | undefined;
constructor() {
this.loadTransport();
}
async loadTransport() {
if (process.env.SMTP_HOST) {
const smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure:
"SMTP_SECURE" in process.env
? process.env.SMTP_SECURE === "true"
: process.env.NODE_ENV === "production",
auth: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? {
ciphers: process.env.SMTP_TLS_CIPHERS,
}
: undefined,
};
if (process.env.SMTP_USERNAME) {
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ user: string; pass: string | undefined; }'... Remove this comment to see the full error message
smtpConfig.auth = {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
};
}
// @ts-expect-error config
this.transporter = nodemailer.createTransport(smtpConfig);
return;
}
if (useTestEmailService) {
Logger.info(
"email",
"SMTP_USERNAME not provided, generating test account…"
);
try {
const testAccount = await nodemailer.createTestAccount();
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
this.transporter = nodemailer.createTransport(smtpConfig);
} catch (err) {
Logger.error(
"Couldn't generate a test account with ethereal.email",
err
);
}
}
}
sendMail = async (data: EmailSendOptions): Promise<void> => {
const { transporter } = this;
if (transporter) {
const html = Oy.renderTemplate(data.html, {
title: data.title,
headCSS: [baseStyles, data.headCSS].join(" "),
previewText: data.previewText,
});
try {
Logger.info("email", `Sending email "${data.title}" to ${data.to}`);
const info = await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
to: data.to,
subject: data.title,
html: html,
text: data.text,
});
if (useTestEmailService) {
Logger.info(
"email",
`Preview Url: ${nodemailer.getTestMessageUrl(info)}`
);
}
} catch (err) {
Logger.error(`Error sending email to ${data.to}`, err);
throw err; // Re-throw for queue to re-try
}
}
};
welcome = async (opts: { to: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Welcome to Outline",
previewText:
"Outline is a place for your team to build and share knowledge.",
html: <WelcomeEmail {...opts} />,
text: welcomeEmailText(opts),
});
};
exportSuccess = async (opts: { to: string; id: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Your requested export",
previewText: "Here's your request data export from Outline",
html: <ExportSuccessEmail id={opts.id} teamUrl={opts.teamUrl} />,
text: exportEmailSuccessText,
});
};
exportFailure = async (opts: { to: string; teamUrl: string }) => {
this.sendMail({
to: opts.to,
title: "Your requested export",
previewText: "Sorry, your requested data export has failed",
html: <ExportFailureEmail teamUrl={opts.teamUrl} />,
text: exportEmailFailureText,
});
};
invite = async (
opts: {
to: string;
} & InviteEmailT
) => {
this.sendMail({
to: opts.to,
title: `${opts.actorName} invited you to join ${opts.teamName}s knowledge base`,
previewText:
"Outline is a place for your team to build and share knowledge.",
html: <InviteEmail {...opts} />,
text: inviteEmailText(opts),
});
};
signin = async (opts: { to: string; token: string; teamUrl: string }) => {
const signInLink = signinEmailText(opts);
if (process.env.NODE_ENV === "development") {
Logger.debug("email", `Sign-In link: ${signInLink}`);
}
this.sendMail({
to: opts.to,
title: "Magic signin link",
previewText: "Heres your link to signin to Outline.",
html: <SigninEmail {...opts} />,
text: signInLink,
});
};
documentNotification = async (opts: {
to: string;
eventName: string;
actorName: string;
documentId: string;
teamUrl: string;
collectionName: string;
unsubscribeUrl: string;
}) => {
const document = await Document.unscoped().findByPk(opts.documentId);
invariant(document, "Document not found");
this.sendMail({
to: opts.to,
title: `${document.title}${opts.eventName}`,
previewText: `${opts.actorName} ${opts.eventName} a new document`,
html: <DocumentNotificationEmail document={document} {...opts} />,
text: documentNotificationEmailText({ ...opts, document }),
});
};
collectionNotification = async (opts: {
to: string;
eventName: string;
collectionId: string;
unsubscribeUrl: string;
}) => {
const collection = await Collection.scope("withUser").findByPk(
opts.collectionId
);
invariant(collection, "Collection not found");
this.sendMail({
to: opts.to,
title: `${collection.name}${opts.eventName}`,
previewText: `${collection.user.name} ${opts.eventName} a collection`,
html: <CollectionNotificationEmail collection={collection} {...opts} />,
text: collectionNotificationEmailText({ ...opts, collection }),
});
};
}
const mailer = new Mailer();
export default mailer;

View File

@@ -1,8 +1,9 @@
import fs from "fs";
import invariant from "invariant";
import ExportFailureEmail from "@server/emails/templates/ExportFailureEmail";
import ExportSuccessEmail from "@server/emails/templates/ExportSuccessEmail";
import Logger from "@server/logging/logger";
import { FileOperation, Collection, Event, Team, User } from "@server/models";
import EmailTask from "@server/queues/tasks/EmailTask";
import { Event as TEvent } from "@server/types";
import { uploadToS3FromBuffer } from "@server/utils/s3";
import { archiveCollections } from "@server/utils/zip";
@@ -88,21 +89,15 @@ export default class ExportsProcessor extends BaseProcessor {
});
if (state === "error") {
await EmailTask.schedule({
type: "exportFailure",
options: {
to: user.email,
teamUrl: team.url,
},
await ExportFailureEmail.schedule({
to: user.email,
teamUrl: team.url,
});
} else {
await EmailTask.schedule({
type: "exportSuccess",
options: {
to: user.email,
id: fileOperation.id,
teamUrl: team.url,
},
await ExportSuccessEmail.schedule({
to: user.email,
id: fileOperation.id,
teamUrl: team.url,
});
}
}

View File

@@ -1,5 +1,5 @@
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import { View, NotificationSetting } from "@server/models";
import EmailTask from "@server/queues/tasks/EmailTask";
import {
buildDocument,
buildCollection,
@@ -8,7 +8,7 @@ import {
import { flushdb } from "@server/test/support";
import NotificationsProcessor from "./NotificationsProcessor";
jest.mock("@server/queues/tasks/EmailTask");
jest.mock("@server/emails/templates/DocumentNotificationEmail");
const ip = "127.0.0.1";
beforeEach(() => flushdb());
@@ -39,7 +39,7 @@ describe("documents.publish", () => {
},
ip,
});
expect(EmailTask.schedule).not.toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
test("should send a notification to other users in team", async () => {
@@ -65,7 +65,7 @@ describe("documents.publish", () => {
},
ip,
});
expect(EmailTask.schedule).toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
});
test("should not send a notification to users without collection access", async () => {
@@ -95,7 +95,7 @@ describe("documents.publish", () => {
},
ip,
});
expect(EmailTask.schedule).not.toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
});
@@ -119,7 +119,7 @@ describe("revisions.create", () => {
collectionId: document.collectionId,
teamId: document.teamId,
});
expect(EmailTask.schedule).toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
});
test("should not send a notification if viewed since update", async () => {
@@ -143,7 +143,7 @@ describe("revisions.create", () => {
collectionId: document.collectionId,
teamId: document.teamId,
});
expect(EmailTask.schedule).not.toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
test("should not send a notification to last editor", async () => {
@@ -164,6 +164,6 @@ describe("revisions.create", () => {
collectionId: document.collectionId,
teamId: document.teamId,
});
expect(EmailTask.schedule).not.toHaveBeenCalled();
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,6 @@
import { Op } from "sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import Logger from "@server/logging/logger";
import { APM } from "@server/logging/tracing";
import {
@@ -15,7 +17,6 @@ import {
RevisionEvent,
Event,
} from "@server/types";
import EmailTask from "../tasks/EmailTask";
import BaseProcessor from "./BaseProcessor";
@APM.trace()
@@ -123,17 +124,14 @@ export default class NotificationsProcessor extends BaseProcessor {
continue;
}
await EmailTask.schedule({
type: "documentNotification",
options: {
to: setting.user.email,
eventName,
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: setting.unsubscribeUrl,
},
await DocumentNotificationEmail.schedule({
to: setting.user.email,
eventName,
documentId: document.id,
teamUrl: team.url,
actorName: document.updatedBy.name,
collectionName: collection.name,
unsubscribeUrl: setting.unsubscribeUrl,
});
}
}
@@ -177,14 +175,11 @@ export default class NotificationsProcessor extends BaseProcessor {
continue;
}
await EmailTask.schedule({
type: "collectionNotification",
options: {
to: setting.user.email,
eventName: "created",
collectionId: collection.id,
unsubscribeUrl: setting.unsubscribeUrl,
},
await CollectionNotificationEmail.schedule({
to: setting.user.email,
eventName: "created",
collectionId: collection.id,
unsubscribeUrl: setting.unsubscribeUrl,
});
}
}

View File

@@ -9,6 +9,12 @@ export enum TaskPriority {
}
export default abstract class BaseTask<T> {
/**
* Schedule this task type to be processed asyncronously by a worker.
*
* @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) {
// @ts-expect-error cannot create an instance of an abstract class, we wont
const task = new this();
@@ -21,8 +27,17 @@ export default abstract class BaseTask<T> {
);
}
/**
* Execute the task.
*
* @param props Properties to be used by the task
* @returns A promise that resolves once the task has completed.
*/
public abstract perform(props: T): Promise<void>;
/**
* Job options such as priority and retry strategy, as defined by Bull.
*/
public get options(): JobOptions {
return {
priority: TaskPriority.Normal,

View File

@@ -1,15 +1,23 @@
import emails from "@server/emails/templates";
import { APM } from "@server/logging/tracing";
import mailer, { EmailSendOptions, EmailTypes } from "../../mailer";
import BaseTask from "./BaseTask";
type Props = {
type: EmailTypes;
options: EmailSendOptions;
templateName: string;
props: Record<string, any>;
};
@APM.trace()
export default class EmailTask extends BaseTask<Props> {
public async perform(props: Props) {
await mailer[props.type](props.options);
public async perform({ templateName, props }: Props) {
const EmailClass = emails[templateName];
if (!EmailClass) {
throw new Error(
`Email task "${templateName}" template does not exist. Check the file name matches the class name.`
);
}
const email = new EmailClass(props);
return email.send();
}
}

View File

@@ -1,5 +1,6 @@
import TestServer from "fetch-test-server";
import EmailTask from "@server/queues/tasks/EmailTask";
import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import webService from "@server/services/web";
import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
@@ -24,7 +25,7 @@ describe("email", () => {
});
it("should respond with redirect location when user is SSO enabled", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const user = await buildUser();
const res = await server.post("/auth/email", {
body: {
@@ -42,7 +43,7 @@ describe("email", () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const user = await buildUser();
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
await buildTeam({
subdomain: "example",
});
@@ -62,7 +63,7 @@ describe("email", () => {
});
it("should respond with success when user is not SSO enabled", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(SigninEmail, "schedule");
const user = await buildGuestUser();
const res = await server.post("/auth/email", {
body: {
@@ -77,7 +78,7 @@ describe("email", () => {
});
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const res = await server.post("/auth/email", {
body: {
email: "user@example.com",
@@ -91,7 +92,7 @@ describe("email", () => {
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(SigninEmail, "schedule");
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "sso-user@example.org";
@@ -121,7 +122,7 @@ describe("email", () => {
});
it("should default to current subdomain with guest email", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(SigninEmail, "schedule");
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "guest-user@example.org";
@@ -151,7 +152,7 @@ describe("email", () => {
});
it("should default to custom domain with SSO", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(WelcomeEmail, "schedule");
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
@@ -179,7 +180,7 @@ describe("email", () => {
});
it("should default to custom domain with guest email", async () => {
const spy = jest.spyOn(EmailTask, "schedule");
const spy = jest.spyOn(SigninEmail, "schedule");
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",

View File

@@ -2,11 +2,12 @@ import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import { parseDomain, isCustomSubdomain } from "@shared/utils/domains";
import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import { AuthorizationError } from "@server/errors";
import errorHandling from "@server/middlewares/errorHandling";
import methodOverride from "@server/middlewares/methodOverride";
import { User, Team } from "@server/models";
import EmailTask from "@server/queues/tasks/EmailTask";
import { signIn } from "@server/utils/authentication";
import { isCustomDomain } from "@server/utils/domains";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
@@ -110,13 +111,10 @@ router.post("email", errorHandling(), async (ctx) => {
}
// send email to users registered address with a short-lived token
await EmailTask.schedule({
type: "signin",
options: {
to: user.email,
token: user.getEmailSigninToken(),
teamUrl: team.url,
},
await SigninEmail.schedule({
to: user.email,
token: user.getEmailSigninToken(),
teamUrl: team.url,
});
user.lastSigninEmailSentAt = new Date();
await user.save();
@@ -150,12 +148,9 @@ router.get("email.callback", async (ctx) => {
}
if (user.isInvited) {
await EmailTask.schedule({
type: "welcome",
options: {
to: user.email,
teamUrl: user.team.url,
},
await WelcomeEmail.schedule({
to: user.email,
teamUrl: user.team.url,
});
}

View File

@@ -9,7 +9,6 @@ import mount from "koa-mount";
import enforceHttps from "koa-sslify";
import env from "@server/env";
import Logger from "@server/logging/logger";
import emails from "../emails";
import routes from "../routes";
import api from "../routes/api";
import auth from "../routes/auth";
@@ -92,7 +91,6 @@ export default function init(app: Koa = new Koa()): Koa {
})
)
);
app.use(mount("/emails", emails));
}
app.use(mount("/auth", auth));