chore: Email + mailer refactor (#3342)

* Huge email refactor

* fix: One rename too many

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
export type Props = {
teamUrl: string;
};
export const welcomeEmailText = ({ teamUrl }: Props) => `
Welcome to Outline!
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.
You can also import existing Markdown documents by dragging and dropping them to your collections.
${teamUrl}/home
`;
export const WelcomeEmail = ({ teamUrl }: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Welcome to Outline!</Heading>
<p>Outline is a place for your team to build and share knowledge.</p>
<p>
To get started, head to your dashboard and try creating a collection
to help document your workflow, create playbooks or help with team
onboarding.
</p>
<p>
You can also import existing Markdown documents by dragging and
dropping them to your collections.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import * as React from "react";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
name: string;
actorName: string;
actorEmail: string;
teamName: string;
teamUrl: string;
};
/**
* Email sent to an external user when an admin sends them an invite.
*/
export default class InviteEmail extends BaseEmail<Props> {
protected subject({ actorName, teamName }: Props) {
return `${actorName} invited you to join ${teamName}s knowledge base`;
}
protected preview() {
return "Outline is a place for your team to build and share knowledge.";
}
protected renderAsText({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props): string {
return `
Join ${teamName} on Outline
${actorName} (${actorEmail}) has invited you to join Outline, a place for your team to build and share knowledge.
Join now: ${teamUrl}
`;
}
protected render({ teamName, actorName, actorEmail, teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Join {teamName} on Outline</Heading>
<p>
{actorName} ({actorEmail}) has invited you to join Outline, a place
for your team to build and share knowledge.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

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

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = {
to: string;
teamUrl: string;
};
/**
* Email sent to a user when their account has just been created, or they signed
* in for the first time from an invite.
*/
export default class WelcomeEmail extends BaseEmail<Props> {
protected subject() {
return "Welcome to Outline";
}
protected preview() {
return "Outline is a place for your team to build and share knowledge.";
}
protected renderAsText({ teamUrl }: Props) {
return `
Welcome to Outline!
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.
You can also import existing Markdown documents by dragging and dropping them to your collections.
${teamUrl}/home
`;
}
protected render({ teamUrl }: Props) {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Welcome to Outline!</Heading>
<p>Outline is a place for your team to build and share knowledge.</p>
<p>
To get started, head to your dashboard and try creating a collection
to help document your workflow, create playbooks or help with team
onboarding.
</p>
<p>
You can also import existing Markdown documents by dragging and
dropping them to your collections.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}

View File

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