Compare commits

...

1 Commits

Author SHA1 Message Date
Tom Moor 0df1f0b4cb Refactor email unsubscribe, remove need to query user 2023-09-01 23:26:17 -04:00
16 changed files with 162 additions and 136 deletions
+10 -2
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.
*
@@ -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>
+23 -17
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 />
@@ -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 />
@@ -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`;
}
@@ -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`;
}
@@ -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 />
@@ -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 { 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 BaseEmail, { EmailProps } from "./BaseEmail";
@@ -44,12 +44,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 +87,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 +132,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 />
+16 -11
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 (
+16 -11
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 (
+16 -15
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 />
@@ -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`;
}
@@ -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>
+1 -4
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}`;
}
@@ -4,7 +4,6 @@ import {
NotificationEventType,
} from "@shared/types";
import env from "@server/env";
import User from "../User";
/**
* Helper class for working with notification settings
@@ -24,22 +23,28 @@ export default class NotificationSettingsHelper {
* to unsubscribe from a specific event without being signed in, for one-click
* links in emails.
*
* @param user The user to unsubscribe
* @param userId The user ID to unsubscribe
* @param eventType The event type to unsubscribe from
* @returns The unsubscribe URL
*/
public static unsubscribeUrl(user: User, eventType: NotificationEventType) {
public static unsubscribeUrl(
userId: string,
eventType: NotificationEventType
) {
return `${
env.URL
}/api/notifications.unsubscribe?token=${this.unsubscribeToken(
user,
userId,
eventType
)}&userId=${user.id}&eventType=${eventType}`;
)}&userId=${userId}&eventType=${eventType}`;
}
public static unsubscribeToken(user: User, eventType: NotificationEventType) {
public static unsubscribeToken(
userId: string,
eventType: NotificationEventType
) {
const hash = crypto.createHash("sha256");
hash.update(`${user.id}-${env.SECRET_KEY}-${eventType}`);
hash.update(`${userId}-${env.SECRET_KEY}-${eventType}`);
return hash.digest("hex");
}
}
@@ -37,7 +37,7 @@ const handleUnsubscribe = async (
rejectOnEmpty: true,
});
const unsubscribeToken = NotificationSettingsHelper.unsubscribeToken(
user,
userId,
eventType
);