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

@@ -0,0 +1,31 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { QueryNotices } from "@shared/types";
import useQuery from "./useQuery";
import useToasts from "./useToasts";
/**
* Display a toast message based on a notice in the query string. This is usually
* used when redirecting from an external source to the client, such as OAuth,
* or emails.
*/
export default function useQueryNotices() {
const query = useQuery();
const { t } = useTranslation();
const { showToast } = useToasts();
const notice = query.get("notice") as QueryNotices;
React.useEffect(() => {
switch (notice) {
case QueryNotices.UnsubscribeDocument: {
showToast(
t("Unsubscribed from document", {
type: "success",
})
);
break;
}
default:
}
}, [t, showToast, notice]);
}

View File

@@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect";
import DelayedMount from "~/components/DelayedMount"; import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading"; import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute"; import Route from "~/components/ProfiledRoute";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazyWithRetry from "~/utils/lazyWithRetry"; import lazyWithRetry from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
@@ -14,6 +15,8 @@ const Login = lazyWithRetry(() => import("~/scenes/Login"));
const Logout = lazyWithRetry(() => import("~/scenes/Logout")); const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
export default function Routes() { export default function Routes() {
useQueryNotices();
return ( return (
<React.Suspense <React.Suspense
fallback={ fallback={

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,7 @@ type Props = EmailProps & {
/** /**
* Email sent to a user when they request to delete their workspace. * Email sent to a user when they request to delete their workspace.
*/ */
export default class ConfirmTeamDeleteEmail extends BaseEmail< export default class ConfirmTeamDeleteEmail extends BaseEmail<Props> {
Props,
Record<string, any>
> {
protected subject() { protected subject() {
return `Your workspace deletion request`; 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. * Email sent to a user when they request to delete their account.
*/ */
export default class ConfirmUserDeleteEmail extends BaseEmail< export default class ConfirmUserDeleteEmail extends BaseEmail<Props> {
Props,
Record<string, any>
> {
protected subject() { protected subject() {
return `Your account deletion request`; 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`; const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
return ( return (
<EmailTemplate <EmailTemplate
previewText={this.preview({ actorName } as Props)} previewText={this.preview(props)}
goToAction={{ url: documentLink, name: "View Document" }} goToAction={{ url: documentLink, name: "View Document" }}
> >
<Header /> <Header />

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import { NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail"; import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body"; import Body from "./components/Body";
@@ -12,34 +11,40 @@ import Footer from "./components/Footer";
import Header from "./components/Header"; import Header from "./components/Header";
import Heading from "./components/Heading"; import Heading from "./components/Heading";
type Props = EmailProps & { type InputProps = EmailProps & {
userId: string; userId: string;
id: string; id: string;
teamUrl: string; teamUrl: string;
teamId: string; teamId: string;
}; };
type BeforeSendProps = { type BeforeSend = {
unsubscribeUrl: string; unsubscribeUrl: string;
}; };
type Props = BeforeSend & InputProps;
/** /**
* Email sent to a user when their data export has completed and is available * Email sent to a user when their data export has completed and is available
* for download in the settings section. * for download in the settings section.
*/ */
export default class ExportSuccessEmail extends BaseEmail< export default class ExportSuccessEmail extends BaseEmail<
Props, InputProps,
BeforeSendProps BeforeSend
> { > {
protected async beforeSend({ userId }: Props) { protected async beforeSend(props: InputProps) {
return { return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( unsubscribeUrl: this.unsubscribeUrl(props),
await User.findByPk(userId, { rejectOnEmpty: true }),
NotificationEventType.ExportCompleted
),
}; };
} }
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.ExportCompleted
);
}
protected subject() { protected subject() {
return "Your requested export"; 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}`; const downloadLink = `${teamUrl}/api/fileOperations.redirect?id=${id}`;
return ( return (

View File

@@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import { NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { User } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import BaseEmail, { EmailProps } from "./BaseEmail"; import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body"; import Body from "./components/Body";
@@ -12,32 +11,38 @@ import Footer from "./components/Footer";
import Header from "./components/Header"; import Header from "./components/Header";
import Heading from "./components/Heading"; import Heading from "./components/Heading";
type Props = EmailProps & { type InputProps = EmailProps & {
inviterId: string; inviterId: string;
invitedName: string; invitedName: string;
teamUrl: string; teamUrl: string;
}; };
type BeforeSendProps = { type BeforeSend = {
unsubscribeUrl: string; unsubscribeUrl: string;
}; };
type Props = InputProps & BeforeSend;
/** /**
* Email sent to a user when someone they invited successfully signs up. * Email sent to a user when someone they invited successfully signs up.
*/ */
export default class InviteAcceptedEmail extends BaseEmail< export default class InviteAcceptedEmail extends BaseEmail<
Props, InputProps,
BeforeSendProps BeforeSend
> { > {
protected async beforeSend({ inviterId }: Props) { protected async beforeSend(props: InputProps) {
return { return {
unsubscribeUrl: NotificationSettingsHelper.unsubscribeUrl( unsubscribeUrl: this.unsubscribeUrl(props),
await User.findByPk(inviterId, { rejectOnEmpty: true }),
NotificationEventType.InviteAccepted
),
}; };
} }
protected unsubscribeUrl({ inviterId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
inviterId,
NotificationEventType.InviteAccepted
);
}
protected subject({ invitedName }: Props) { protected subject({ invitedName }: Props) {
return `${invitedName} has joined your ${env.APP_NAME} team`; return `${invitedName} has joined your ${env.APP_NAME} team`;
} }
@@ -54,11 +59,7 @@ Open ${env.APP_NAME}: ${teamUrl}
`; `;
} }
protected render({ protected render({ invitedName, teamUrl, unsubscribeUrl }: Props) {
invitedName,
teamUrl,
unsubscribeUrl,
}: Props & BeforeSendProps) {
return ( return (
<EmailTemplate previewText={this.preview({ invitedName } as Props)}> <EmailTemplate previewText={this.preview({ invitedName } as Props)}>
<Header /> <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 * Email sent to an external user when an admin sends them an invite and they
* haven't signed in after a few days. * haven't signed in after a few days.
*/ */
export default class InviteReminderEmail extends BaseEmail< export default class InviteReminderEmail extends BaseEmail<Props> {
Props,
Record<string, any>
> {
protected subject({ actorName, teamName }: Props) { protected subject({ actorName, teamName }: Props) {
return `Reminder: ${actorName} invited you to join ${teamName}s knowledge base`; 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 * Email sent to the creator of a webhook when the webhook has become disabled
* due to repeated failure. * due to repeated failure.
*/ */
export default class WebhookDisabledEmail extends BaseEmail< export default class WebhookDisabledEmail extends BaseEmail<Props> {
Props,
Record<string, any>
> {
protected subject() { protected subject() {
return `Warning: Webhook disabled`; 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`; const webhookSettingsLink = `${teamUrl}/settings/webhooks`;
return ( return (
<EmailTemplate previewText={this.preview({ webhookName } as Props)}> <EmailTemplate previewText={this.preview(props)}>
<Header /> <Header />
<Body> <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 * Email sent to a user when their account has just been created, or they signed
* in for the first time from an invite. * in for the first time from an invite.
*/ */
export default class WelcomeEmail extends BaseEmail< export default class WelcomeEmail extends BaseEmail<Props> {
Props,
Record<string, any>
> {
protected subject() { protected subject() {
return `Welcome to ${env.APP_NAME}`; return `Welcome to ${env.APP_NAME}`;
} }

View File

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

View File

@@ -4,7 +4,6 @@ import {
NotificationEventType, NotificationEventType,
} from "@shared/types"; } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import User from "../User";
/** /**
* Helper class for working with notification settings * 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 * to unsubscribe from a specific event without being signed in, for one-click
* links in emails. * links in emails.
* *
* @param user The user to unsubscribe * @param userId The user ID to unsubscribe
* @param eventType The event type to unsubscribe from * @param eventType The event type to unsubscribe from
* @returns The unsubscribe URL * @returns The unsubscribe URL
*/ */
public static unsubscribeUrl(user: User, eventType: NotificationEventType) { public static unsubscribeUrl(
userId: string,
eventType: NotificationEventType
) {
return `${ return `${
env.URL env.URL
}/api/notifications.unsubscribe?token=${this.unsubscribeToken( }/api/notifications.unsubscribe?token=${this.unsubscribeToken(
user, userId,
eventType 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"); const hash = crypto.createHash("sha256");
hash.update(`${user.id}-${env.SECRET_KEY}-${eventType}`); hash.update(`${userId}-${env.SECRET_KEY}-${eventType}`);
return hash.digest("hex"); return hash.digest("hex");
} }
} }

View File

@@ -0,0 +1,29 @@
import crypto from "crypto";
import env from "@server/env";
/**
* Helper class for working with subscription settings
*/
export default class SubscriptionHelper {
/**
* Get the unsubscribe URL for a user and document. This url allows the user
* to unsubscribe from a specific document without being signed in, for one-click
* links in emails.
*
* @param userId The user ID to unsubscribe
* @param documentId The document ID to unsubscribe from
* @returns The unsubscribe URL
*/
public static unsubscribeUrl(userId: string, documentId: string) {
return `${env.URL}/api/subscriptions.delete?token=${this.unsubscribeToken(
userId,
documentId
)}&userId=${userId}&documentId=${documentId}`;
}
public static unsubscribeToken(userId: string, documentId: string) {
const hash = crypto.createHash("sha256");
hash.update(`${userId}-${env.SECRET_KEY}-${documentId}`);
return hash.digest("hex");
}
}

View File

@@ -33,11 +33,8 @@ const handleUnsubscribe = async (
const userId = (ctx.input.body.userId ?? ctx.input.query.userId) as string; const userId = (ctx.input.body.userId ?? ctx.input.query.userId) as string;
const token = (ctx.input.body.token ?? ctx.input.query.token) as string; const token = (ctx.input.body.token ?? ctx.input.query.token) as string;
const user = await User.scope("withTeam").findByPk(userId, {
rejectOnEmpty: true,
});
const unsubscribeToken = NotificationSettingsHelper.unsubscribeToken( const unsubscribeToken = NotificationSettingsHelper.unsubscribeToken(
user, userId,
eventType eventType
); );
@@ -46,6 +43,10 @@ const handleUnsubscribe = async (
return; return;
} }
const user = await User.scope("withTeam").findByPk(userId, {
rejectOnEmpty: true,
});
user.setNotificationEventType(eventType, false); user.setNotificationEventType(eventType, false);
await user.save(); await user.save();
ctx.redirect(`${user.team.url}/settings/notifications?success`); ctx.redirect(`${user.team.url}/settings/notifications?success`);

View File

@@ -42,3 +42,15 @@ export const SubscriptionsDeleteSchema = BaseSchema.extend({
}); });
export type SubscriptionsDeleteReq = z.infer<typeof SubscriptionsDeleteSchema>; export type SubscriptionsDeleteReq = z.infer<typeof SubscriptionsDeleteSchema>;
export const SubscriptionsDeleteTokenSchema = BaseSchema.extend({
query: z.object({
userId: z.string().uuid(),
documentId: z.string().uuid(),
token: z.string(),
}),
});
export type SubscriptionsDeleteTokenReq = z.infer<
typeof SubscriptionsDeleteTokenSchema
>;

View File

@@ -1,13 +1,19 @@
import Router from "koa-router"; import Router from "koa-router";
import { Transaction } from "sequelize";
import { QueryNotices } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator"; import subscriptionCreator from "@server/commands/subscriptionCreator";
import subscriptionDestroyer from "@server/commands/subscriptionDestroyer"; import subscriptionDestroyer from "@server/commands/subscriptionDestroyer";
import env from "@server/env";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction"; import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate"; import validate from "@server/middlewares/validate";
import { Subscription, Document } from "@server/models"; import { Subscription, Document, User } from "@server/models";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import { authorize } from "@server/policies"; import { authorize } from "@server/policies";
import { presentSubscription } from "@server/presenters"; import { presentSubscription } from "@server/presenters";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination"; import pagination from "../middlewares/pagination";
import * as T from "./schema"; import * as T from "./schema";
@@ -103,6 +109,51 @@ router.post(
} }
); );
router.get(
"subscriptions.delete",
validate(T.SubscriptionsDeleteTokenSchema),
rateLimiter(RateLimiterStrategy.FivePerMinute),
transaction(),
async (ctx: APIContext<T.SubscriptionsDeleteTokenReq>) => {
const { transaction } = ctx.state;
const { userId, documentId, token } = ctx.input.query;
const unsubscribeToken = SubscriptionHelper.unsubscribeToken(
userId,
documentId
);
if (unsubscribeToken !== token) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
const [subscription, user] = await Promise.all([
Subscription.findOne({
where: {
userId,
documentId,
},
lock: Transaction.LOCK.UPDATE,
rejectOnEmpty: true,
transaction,
}),
User.scope("withTeam").findByPk(userId, {
rejectOnEmpty: true,
transaction,
}),
]);
authorize(user, "delete", subscription);
await subscription.destroy({ transaction });
ctx.redirect(
`${user.team.url}/home?notice=${QueryNotices.UnsubscribeDocument}`
);
}
);
router.post( router.post(
"subscriptions.delete", "subscriptions.delete",
auth(), auth(),

View File

@@ -345,6 +345,7 @@
"Indent": "Indent", "Indent": "Indent",
"Outdent": "Outdent", "Outdent": "Outdent",
"Could not import file": "Could not import file", "Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Account": "Account", "Account": "Account",
"API Tokens": "API Tokens", "API Tokens": "API Tokens",
"Details": "Details", "Details": "Details",

View File

@@ -219,6 +219,10 @@ export enum UnfurlType {
Document = "document", Document = "document",
} }
export enum QueryNotices {
UnsubscribeDocument = "unsubscribe-document",
}
export type OEmbedType = "photo" | "video" | "rich"; export type OEmbedType = "photo" | "video" | "rich";
export type Unfurl<T = OEmbedType> = { export type Unfurl<T = OEmbedType> = {