chore: Email + mailer refactor (#3342)
* Huge email refactor * fix: One rename too many * comments
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user