- {actorName} {eventName} the document "{document.title}", in the{" "}
- {collectionName} collection.
-
-
-
-
{document.getSummary()}
-
-
-
-
-
-
-
-
- );
-};
diff --git a/server/emails/ExportFailureEmail.tsx b/server/emails/ExportFailureEmail.tsx
deleted file mode 100644
index 0d16022bb..000000000
--- a/server/emails/ExportFailureEmail.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/server/emails/ExportSuccessEmail.tsx b/server/emails/ExportSuccessEmail.tsx
deleted file mode 100644
index b582fb9c3..000000000
--- a/server/emails/ExportSuccessEmail.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Your Data Export
-
- Your requested data export is complete, the exported files are also
- available in the{" "}
-
- admin section
-
- .
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/server/emails/InviteEmail.tsx b/server/emails/InviteEmail.tsx
deleted file mode 100644
index 81dd3da2e..000000000
--- a/server/emails/InviteEmail.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Join {teamName} on Outline
-
- {actorName} ({actorEmail}) has invited you to join Outline, a place
- for your team to build and share knowledge.
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/server/emails/SigninEmail.tsx b/server/emails/SigninEmail.tsx
deleted file mode 100644
index cc9357af2..000000000
--- a/server/emails/SigninEmail.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Magic Sign-in Link
-
Click the button below to sign in to Outline.
-
-
-
-
-
-
- If your magic link expired you can request a new one from your team’s
- sign-in page at: {teamUrl}
-
-
-
-
-
- );
-};
diff --git a/server/emails/WelcomeEmail.tsx b/server/emails/WelcomeEmail.tsx
deleted file mode 100644
index 92aa5b138..000000000
--- a/server/emails/WelcomeEmail.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/server/emails/index.ts b/server/emails/index.ts
deleted file mode 100644
index b37ef1b74..000000000
--- a/server/emails/index.ts
+++ /dev/null
@@ -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;
diff --git a/server/emails/mailer.tsx b/server/emails/mailer.tsx
new file mode 100644
index 000000000..259a4fa5a
--- /dev/null
+++ b/server/emails/mailer.tsx
@@ -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 => {
+ 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;
diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx
new file mode 100644
index 000000000..1139d5510
--- /dev/null
+++ b/server/emails/templates/BaseEmail.tsx
@@ -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 {
+ 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(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;
+}
diff --git a/server/emails/templates/CollectionNotificationEmail.tsx b/server/emails/templates/CollectionNotificationEmail.tsx
new file mode 100644
index 000000000..1a37861e6
--- /dev/null
+++ b/server/emails/templates/CollectionNotificationEmail.tsx
@@ -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 (
+
+
+
+
+ {collection.name}
+
+ {collection.user.name} {eventName} the collection "{collection.name}
+ ".
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/templates/DocumentNotificationEmail.tsx b/server/emails/templates/DocumentNotificationEmail.tsx
new file mode 100644
index 000000000..167918903
--- /dev/null
+++ b/server/emails/templates/DocumentNotificationEmail.tsx
@@ -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 (
+
+
+
+
+
+ "{document.title}" {eventName}
+
+
+ {actorName} {eventName} the document "{document.title}", in the{" "}
+ {collectionName} collection.
+
+
+
+
{document.getSummary()}
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/templates/ExportFailureEmail.tsx b/server/emails/templates/ExportFailureEmail.tsx
new file mode 100644
index 000000000..806b132ea
--- /dev/null
+++ b/server/emails/templates/ExportFailureEmail.tsx
@@ -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 {
+ 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 (
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/templates/ExportSuccessEmail.tsx b/server/emails/templates/ExportSuccessEmail.tsx
new file mode 100644
index 000000000..f39381990
--- /dev/null
+++ b/server/emails/templates/ExportSuccessEmail.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ Your Data Export
+
+ Your requested data export is complete, the exported files are also
+ available in the{" "}
+
+ admin section
+
+ .
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/templates/InviteEmail.tsx b/server/emails/templates/InviteEmail.tsx
new file mode 100644
index 000000000..c7259fa56
--- /dev/null
+++ b/server/emails/templates/InviteEmail.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ Join {teamName} on Outline
+
+ {actorName} ({actorEmail}) has invited you to join Outline, a place
+ for your team to build and share knowledge.
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/templates/SigninEmail.tsx b/server/emails/templates/SigninEmail.tsx
new file mode 100644
index 000000000..898009ab2
--- /dev/null
+++ b/server/emails/templates/SigninEmail.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ Magic Sign-in Link
+
Click the button below to sign in to Outline.
+
+
+
+
+
+
+ If your magic link expired you can request a new one from your
+ team’s sign-in page at: {teamUrl}
+
+
+
+
+
+ );
+ }
+
+ private signinLink(token: string): string {
+ return `${process.env.URL}/auth/email.callback?token=${token}`;
+ }
+}
diff --git a/server/emails/templates/WelcomeEmail.tsx b/server/emails/templates/WelcomeEmail.tsx
new file mode 100644
index 000000000..47e4c0da1
--- /dev/null
+++ b/server/emails/templates/WelcomeEmail.tsx
@@ -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 {
+ 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 (
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/server/emails/components/Body.tsx b/server/emails/templates/components/Body.tsx
similarity index 100%
rename from server/emails/components/Body.tsx
rename to server/emails/templates/components/Body.tsx
diff --git a/server/emails/components/Button.tsx b/server/emails/templates/components/Button.tsx
similarity index 100%
rename from server/emails/components/Button.tsx
rename to server/emails/templates/components/Button.tsx
diff --git a/server/emails/components/EmailLayout.tsx b/server/emails/templates/components/EmailLayout.tsx
similarity index 100%
rename from server/emails/components/EmailLayout.tsx
rename to server/emails/templates/components/EmailLayout.tsx
diff --git a/server/emails/components/EmptySpace.tsx b/server/emails/templates/components/EmptySpace.tsx
similarity index 100%
rename from server/emails/components/EmptySpace.tsx
rename to server/emails/templates/components/EmptySpace.tsx
diff --git a/server/emails/components/Footer.tsx b/server/emails/templates/components/Footer.tsx
similarity index 100%
rename from server/emails/components/Footer.tsx
rename to server/emails/templates/components/Footer.tsx
diff --git a/server/emails/components/Header.tsx b/server/emails/templates/components/Header.tsx
similarity index 100%
rename from server/emails/components/Header.tsx
rename to server/emails/templates/components/Header.tsx
diff --git a/server/emails/components/Heading.tsx b/server/emails/templates/components/Heading.tsx
similarity index 100%
rename from server/emails/components/Heading.tsx
rename to server/emails/templates/components/Heading.tsx
diff --git a/server/emails/templates/index.ts b/server/emails/templates/index.ts
new file mode 100644
index 000000000..bdef23ec6
--- /dev/null
+++ b/server/emails/templates/index.ts
@@ -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;
diff --git a/server/mailer.test.ts b/server/mailer.test.ts
deleted file mode 100644
index 56ef95a5e..000000000
--- a/server/mailer.test.ts
+++ /dev/null
@@ -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();
- });
-});
diff --git a/server/mailer.tsx b/server/mailer.tsx
deleted file mode 100644
index aedddcff3..000000000
--- a/server/mailer.tsx
+++ /dev/null
@@ -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 => {
- 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: ,
- 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: ,
- 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: ,
- 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: ,
- 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: ,
- 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: ,
- 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: ,
- text: collectionNotificationEmailText({ ...opts, collection }),
- });
- };
-}
-
-const mailer = new Mailer();
-
-export default mailer;
diff --git a/server/queues/processors/ExportsProcessor.ts b/server/queues/processors/ExportsProcessor.ts
index a74b71b6e..65a116204 100644
--- a/server/queues/processors/ExportsProcessor.ts
+++ b/server/queues/processors/ExportsProcessor.ts
@@ -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,
});
}
}
diff --git a/server/queues/processors/NotificationsProcessor.test.ts b/server/queues/processors/NotificationsProcessor.test.ts
index 90870fdd2..e70a4fed8 100644
--- a/server/queues/processors/NotificationsProcessor.test.ts
+++ b/server/queues/processors/NotificationsProcessor.test.ts
@@ -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();
});
});
diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts
index 54682c437..101b20618 100644
--- a/server/queues/processors/NotificationsProcessor.ts
+++ b/server/queues/processors/NotificationsProcessor.ts
@@ -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,
});
}
}
diff --git a/server/queues/tasks/BaseTask.ts b/server/queues/tasks/BaseTask.ts
index 9d985121a..43fe81318 100644
--- a/server/queues/tasks/BaseTask.ts
+++ b/server/queues/tasks/BaseTask.ts
@@ -9,6 +9,12 @@ export enum TaskPriority {
}
export default abstract class BaseTask {
+ /**
+ * 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(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 {
);
}
+ /**
+ * 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;
+ /**
+ * Job options such as priority and retry strategy, as defined by Bull.
+ */
public get options(): JobOptions {
return {
priority: TaskPriority.Normal,
diff --git a/server/queues/tasks/EmailTask.ts b/server/queues/tasks/EmailTask.ts
index 362ca0ce0..3384a7966 100644
--- a/server/queues/tasks/EmailTask.ts
+++ b/server/queues/tasks/EmailTask.ts
@@ -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;
};
@APM.trace()
export default class EmailTask extends BaseTask {
- 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();
}
}
diff --git a/server/routes/auth/providers/email.test.ts b/server/routes/auth/providers/email.test.ts
index 5e151a5cd..43d0e7015 100644
--- a/server/routes/auth/providers/email.test.ts
+++ b/server/routes/auth/providers/email.test.ts
@@ -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",
diff --git a/server/routes/auth/providers/email.ts b/server/routes/auth/providers/email.ts
index 4ce575a87..5fc6b1d30 100644
--- a/server/routes/auth/providers/email.ts
+++ b/server/routes/auth/providers/email.ts
@@ -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,
});
}
diff --git a/server/services/web.ts b/server/services/web.ts
index ffea8af41..9ec1c5fd7 100644
--- a/server/services/web.ts
+++ b/server/services/web.ts
@@ -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));