Add document unsubscribe link in email footer (#5762)

This commit is contained in:
Tom Moor
2023-09-03 19:04:28 -04:00
committed by GitHub
parent 0261e0712c
commit d7c331532d
24 changed files with 347 additions and 161 deletions

View File

@@ -14,7 +14,7 @@ export interface EmailProps {
export default abstract class BaseEmail<
T extends EmailProps,
S extends Record<string, any>
S extends Record<string, any> | void = void
> {
private props: T;
private metadata?: NotificationMetadata;
@@ -106,7 +106,7 @@ export default abstract class BaseEmail<
),
text: this.renderAsText(data),
headCSS: this.headCSS?.(data),
unsubscribeUrl: data.unsubscribeUrl,
unsubscribeUrl: this.unsubscribeUrl?.(data),
});
Metrics.increment("email.sent", {
templateName,
@@ -167,6 +167,14 @@ export default abstract class BaseEmail<
*/
protected abstract render(props: S & T): JSX.Element;
/**
* Returns the unsubscribe URL for the email.
*
* @param props Props in email constructor
* @returns The unsubscribe URL as a string
*/
protected unsubscribeUrl?(props: T): string;
/**
* Allows injecting additional CSS into the head of the email.
*

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Collection, User } from "@server/models";
import { Collection } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
@@ -32,9 +32,9 @@ export default class CollectionCreatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ userId, collectionId }: Props) {
protected async beforeSend(props: InputProps) {
const collection = await Collection.scope("withUser").findByPk(
collectionId
props.collectionId
);
if (!collection) {
return false;
@@ -42,13 +42,17 @@ export default class CollectionCreatedEmail extends BaseEmail<
return {
collection,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.CreateCollection
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.CreateCollection
);
}
protected subject({ collection }: Props) {
return `${collection.name}” created`;
}
@@ -67,12 +71,13 @@ Open Collection: ${teamUrl}${collection.url}
`;
}
protected render({ collection, teamUrl, unsubscribeUrl }: Props) {
protected render(props: Props) {
const { collection, teamUrl, unsubscribeUrl } = props;
const collectionLink = `${teamUrl}${collection.url}`;
return (
<EmailTemplate
previewText={this.preview({ collection } as Props)}
previewText={this.preview(props)}
goToAction={{ url: collectionLink, name: "View Collection" }}
>
<Header />
@@ -80,8 +85,7 @@ Open Collection: ${teamUrl}${collection.url}
<Body>
<Heading>{collection.name}</Heading>
<p>
{collection.user.name} created the collection "{collection.name}
".
{collection.user.name} created the collection "{collection.name}".
</p>
<EmptySpace height={10} />
<p>

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
import env from "@server/env";
import { Collection, Comment, Document, User } from "@server/models";
import { Collection, Comment, Document } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
@@ -44,7 +44,8 @@ export default class CommentCreatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId, userId, commentId }: InputProps) {
protected async beforeSend(props: InputProps) {
const { documentId, commentId } = props;
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
@@ -99,13 +100,17 @@ export default class CommentCreatedEmail extends BaseEmail<
isReply,
isFirstComment,
body,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.CreateComment
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.CreateComment
);
}
protected subject({ isFirstComment, document }: Props) {
return `${isFirstComment ? "" : "Re: "}New comment on “${document.title}`;
}
@@ -137,21 +142,22 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render({
document,
actorName,
isReply,
collection,
teamUrl,
commentId,
unsubscribeUrl,
body,
}: Props) {
protected render(props: Props) {
const {
document,
actorName,
isReply,
collection,
teamUrl,
commentId,
unsubscribeUrl,
body,
} = props;
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview({ isReply, actorName } as Props)}
previewText={this.preview(props)}
goToAction={{ url: threadLink, name: "View Thread" }}
>
<Header />

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
import env from "@server/env";
import { Collection, Comment, Document, User } from "@server/models";
import { Collection, Comment, Document } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
@@ -42,7 +42,8 @@ export default class CommentMentionedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId, commentId, userId }: InputProps) {
protected async beforeSend(props: InputProps) {
const { documentId, commentId } = props;
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
@@ -86,13 +87,17 @@ export default class CommentMentionedEmail extends BaseEmail<
document,
collection,
body,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.MentionedInComment
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.MentionedInComment
);
}
protected subject({ actorName, document }: Props) {
return `${actorName} mentioned you in “${document.title}`;
}
@@ -121,20 +126,21 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render({
document,
collection,
actorName,
teamUrl,
commentId,
unsubscribeUrl,
body,
}: Props) {
protected render(props: Props) {
const {
document,
collection,
actorName,
teamUrl,
commentId,
unsubscribeUrl,
body,
} = props;
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview({ actorName } as Props)}
previewText={this.preview(props)}
goToAction={{ url: threadLink, name: "View Thread" }}
>
<Header />

View File

@@ -16,10 +16,7 @@ type Props = EmailProps & {
/**
* Email sent to a user when they request to delete their workspace.
*/
export default class ConfirmTeamDeleteEmail extends BaseEmail<
Props,
Record<string, any>
> {
export default class ConfirmTeamDeleteEmail extends BaseEmail<Props> {
protected subject() {
return `Your workspace deletion request`;
}

View File

@@ -16,10 +16,7 @@ type Props = EmailProps & {
/**
* Email sent to a user when they request to delete their account.
*/
export default class ConfirmUserDeleteEmail extends BaseEmail<
Props,
Record<string, any>
> {
export default class ConfirmUserDeleteEmail extends BaseEmail<Props> {
protected subject() {
return `Your account deletion request`;
}

View File

@@ -57,12 +57,13 @@ Open Document: ${teamUrl}${document.url}
`;
}
protected render({ document, actorName, teamUrl }: Props) {
protected render(props: Props) {
const { document, actorName, teamUrl } = props;
const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview({ actorName } as Props)}
previewText={this.preview(props)}
goToAction={{ url: documentLink, name: "View Document" }}
>
<Header />

View File

@@ -3,16 +3,17 @@ import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
import env from "@server/env";
import { Document, Collection, User, Revision } from "@server/models";
import { Document, Collection, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Footer, { Link } from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
@@ -44,12 +45,8 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({
documentId,
revisionId,
eventType,
userId,
}: InputProps) {
protected async beforeSend(props: InputProps) {
const { documentId, revisionId } = props;
const document = await Document.unscoped().findByPk(documentId, {
includeState: true,
});
@@ -91,13 +88,14 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
document,
collection,
body,
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
eventType
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId, eventType }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(userId, eventType);
}
eventName(eventType: NotificationEventType) {
switch (eventType) {
case NotificationEventType.PublishDocument:
@@ -135,21 +133,22 @@ Open Document: ${teamUrl}${document.url}
`;
}
protected render({
document,
actorName,
collection,
eventType,
teamUrl,
unsubscribeUrl,
body,
}: Props) {
protected render(props: Props) {
const {
document,
actorName,
collection,
eventType,
teamUrl,
unsubscribeUrl,
body,
} = props;
const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
const eventName = this.eventName(eventType);
return (
<EmailTemplate
previewText={this.preview({ actorName, eventType } as Props)}
previewText={this.preview(props)}
goToAction={{ url: documentLink, name: "View Document" }}
>
<Header />
@@ -177,7 +176,16 @@ Open Document: ${teamUrl}${document.url}
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
<Footer unsubscribeUrl={unsubscribeUrl}>
<Link
href={SubscriptionHelper.unsubscribeUrl(
props.userId,
props.documentId
)}
>
Unsubscribe from this doc
</Link>
</Footer>
</EmailTemplate>
);
}

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
@@ -11,32 +10,38 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
type InputProps = EmailProps & {
userId: string;
teamUrl: string;
teamId: string;
};
type BeforeSendProps = {
type BeforeSend = {
unsubscribeUrl: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when their data export has failed for some reason.
*/
export default class ExportFailureEmail extends BaseEmail<
Props,
BeforeSendProps
InputProps,
BeforeSend
> {
protected async beforeSend({ userId }: Props) {
protected async beforeSend(props: InputProps) {
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.ExportCompleted
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.ExportCompleted
);
}
protected subject() {
return "Your requested export";
}
@@ -54,7 +59,7 @@ section to try again if the problem persists please contact support.
`;
}
protected render({ teamUrl, unsubscribeUrl }: Props & BeforeSendProps) {
protected render({ teamUrl, unsubscribeUrl }: Props) {
const exportLink = `${teamUrl}/settings/export`;
return (

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
@@ -12,34 +11,40 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
type InputProps = EmailProps & {
userId: string;
id: string;
teamUrl: string;
teamId: string;
};
type BeforeSendProps = {
type BeforeSend = {
unsubscribeUrl: string;
};
type Props = BeforeSend & InputProps;
/**
* 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,
BeforeSendProps
InputProps,
BeforeSend
> {
protected async beforeSend({ userId }: Props) {
protected async beforeSend(props: InputProps) {
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.ExportCompleted
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.ExportCompleted
);
}
protected subject() {
return "Your requested export";
}
@@ -56,7 +61,7 @@ Your requested data export is complete, the exported files are also available in
`;
}
protected render({ id, teamUrl, unsubscribeUrl }: Props & BeforeSendProps) {
protected render({ id, teamUrl, unsubscribeUrl }: Props) {
const downloadLink = `${teamUrl}/api/fileOperations.redirect?id=${id}`;
return (

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
@@ -12,32 +11,38 @@ import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
type InputProps = EmailProps & {
inviterId: string;
invitedName: string;
teamUrl: string;
};
type BeforeSendProps = {
type BeforeSend = {
unsubscribeUrl: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone they invited successfully signs up.
*/
export default class InviteAcceptedEmail extends BaseEmail<
Props,
BeforeSendProps
InputProps,
BeforeSend
> {
protected async beforeSend({ inviterId }: Props) {
protected async beforeSend(props: InputProps) {
return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl(
await User.findByPk(inviterId, { rejectOnEmpty: true }),
NotificationEventType.InviteAccepted
),
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ inviterId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
inviterId,
NotificationEventType.InviteAccepted
);
}
protected subject({ invitedName }: Props) {
return `${invitedName} has joined your ${env.APP_NAME} team`;
}
@@ -54,11 +59,7 @@ Open ${env.APP_NAME}: ${teamUrl}
`;
}
protected render({
invitedName,
teamUrl,
unsubscribeUrl,
}: Props & BeforeSendProps) {
protected render({ invitedName, teamUrl, unsubscribeUrl }: Props) {
return (
<EmailTemplate previewText={this.preview({ invitedName } as Props)}>
<Header />

View File

@@ -21,10 +21,7 @@ type Props = EmailProps & {
* Email sent to an external user when an admin sends them an invite and they
* haven't signed in after a few days.
*/
export default class InviteReminderEmail extends BaseEmail<
Props,
Record<string, any>
> {
export default class InviteReminderEmail extends BaseEmail<Props> {
protected subject({ actorName, teamName }: Props) {
return `Reminder: ${actorName} invited you to join ${teamName}s knowledge base`;
}

View File

@@ -17,10 +17,7 @@ type Props = EmailProps & {
* Email sent to the creator of a webhook when the webhook has become disabled
* due to repeated failure.
*/
export default class WebhookDisabledEmail extends BaseEmail<
Props,
Record<string, any>
> {
export default class WebhookDisabledEmail extends BaseEmail<Props> {
protected subject() {
return `Warning: Webhook disabled`;
}
@@ -38,10 +35,12 @@ Webhook settings: ${teamUrl}/settings/webhooks
`;
}
protected render({ webhookName, teamUrl }: Props) {
protected render(props: Props) {
const { webhookName, teamUrl } = props;
const webhookSettingsLink = `${teamUrl}/settings/webhooks`;
return (
<EmailTemplate previewText={this.preview({ webhookName } as Props)}>
<EmailTemplate previewText={this.preview(props)}>
<Header />
<Body>

View File

@@ -17,10 +17,7 @@ type Props = EmailProps & {
* 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,
Record<string, any>
> {
export default class WelcomeEmail extends BaseEmail<Props> {
protected subject() {
return `Welcome to ${env.APP_NAME}`;
}

View File

@@ -6,39 +6,54 @@ import env from "@server/env";
type Props = {
unsubscribeUrl?: string;
children?: React.ReactNode;
};
export default ({ unsubscribeUrl }: Props) => {
const footerStyle = {
padding: "20px 0",
borderTop: `1px solid ${theme.smokeDark}`,
color: theme.slate,
fontSize: "14px",
};
const unsubStyle = {
padding: "0",
color: theme.slate,
fontSize: "14px",
};
export const Link = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
const linkStyle = {
color: theme.slate,
fontWeight: 500,
textDecoration: "none",
marginRight: "10px",
};
return (
<a href={href} style={linkStyle}>
{children}
</a>
);
};
export default ({ unsubscribeUrl, children }: Props) => {
const footerStyle = {
padding: "20px 0",
borderTop: `1px solid ${theme.smokeDark}`,
color: theme.slate,
fontSize: "14px",
};
const footerLinkStyle = {
padding: "0",
color: theme.slate,
fontSize: "14px",
};
const externalLinkStyle = {
color: theme.slate,
textDecoration: "none",
margin: "0 10px",
};
return (
<Table width="100%">
<TBody>
<TR>
<TD style={footerStyle}>
<a href={env.URL} style={linkStyle}>
{env.APP_NAME}
</a>
<Link href={env.URL}>{env.APP_NAME}</Link>
<a href={twitterUrl()} style={externalLinkStyle}>
Twitter
</a>
@@ -46,13 +61,16 @@ export default ({ unsubscribeUrl }: Props) => {
</TR>
{unsubscribeUrl && (
<TR>
<TD style={unsubStyle}>
<a href={unsubscribeUrl} style={linkStyle}>
Unsubscribe from these emails
</a>
<TD style={footerLinkStyle}>
<Link href={unsubscribeUrl}>Unsubscribe from these emails</Link>
</TD>
</TR>
)}
{children && (
<TR>
<TD style={footerLinkStyle}>{children}</TD>
</TR>
)}
</TBody>
</Table>
);