Add document unsubscribe link in email footer (#5762)
This commit is contained in:
31
app/hooks/useQueryNotices.ts
Normal file
31
app/hooks/useQueryNotices.ts
Normal 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]);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
29
server/models/helpers/SubscriptionHelper.ts
Normal file
29
server/models/helpers/SubscriptionHelper.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user