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

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,7 +142,8 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render({
protected render(props: Props) {
const {
document,
actorName,
isReply,
@@ -146,12 +152,12 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
commentId,
unsubscribeUrl,
body,
}: Props) {
} = 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,7 +126,8 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render({
protected render(props: Props) {
const {
document,
collection,
actorName,
@@ -129,12 +135,12 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
commentId,
unsubscribeUrl,
body,
}: Props) {
} = 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,7 +133,8 @@ Open Document: ${teamUrl}${document.url}
`;
}
protected render({
protected render(props: Props) {
const {
document,
actorName,
collection,
@@ -143,13 +142,13 @@ Open Document: ${teamUrl}${document.url}
teamUrl,
unsubscribeUrl,
body,
}: Props) {
} = 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>
);

View File

@@ -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");
}
}

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 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(
user,
userId,
eventType
);
@@ -46,6 +43,10 @@ const handleUnsubscribe = async (
return;
}
const user = await User.scope("withTeam").findByPk(userId, {
rejectOnEmpty: true,
});
user.setNotificationEventType(eventType, false);
await user.save();
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 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 { Transaction } from "sequelize";
import { QueryNotices } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import subscriptionDestroyer from "@server/commands/subscriptionDestroyer";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
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 { presentSubscription } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
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(
"subscriptions.delete",
auth(),

View File

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

View File

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