chore: Email + mailer refactor (#3342)
* Huge email refactor * fix: One rename too many * comments
This commit is contained in:
@@ -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\\"> </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\\"> </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\\"> </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\\"> </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",
|
||||
}
|
||||
`;
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 team’s
|
||||
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 team’s
|
||||
sign-in page at: <a href={teamUrl}>{teamUrl}</a>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
131
server/emails/mailer.tsx
Normal 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;
|
||||
107
server/emails/templates/BaseEmail.tsx
Normal file
107
server/emails/templates/BaseEmail.tsx
Normal 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>;
|
||||
}
|
||||
88
server/emails/templates/CollectionNotificationEmail.tsx
Normal file
88
server/emails/templates/CollectionNotificationEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
100
server/emails/templates/DocumentNotificationEmail.tsx
Normal file
100
server/emails/templates/DocumentNotificationEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
63
server/emails/templates/ExportFailureEmail.tsx
Normal file
63
server/emails/templates/ExportFailureEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
server/emails/templates/ExportSuccessEmail.tsx
Normal file
69
server/emails/templates/ExportSuccessEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
68
server/emails/templates/InviteEmail.tsx
Normal file
68
server/emails/templates/InviteEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
72
server/emails/templates/SigninEmail.tsx
Normal file
72
server/emails/templates/SigninEmail.tsx
Normal 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 "Here’s 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 team’s
|
||||
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
|
||||
team’s 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}`;
|
||||
}
|
||||
}
|
||||
70
server/emails/templates/WelcomeEmail.tsx
Normal file
70
server/emails/templates/WelcomeEmail.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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
16
server/emails/templates/index.ts
Normal file
16
server/emails/templates/index.ts
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: "Here’s 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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user