feat: Add email subscriptions to public docs (#11911)

* feat: Add email subscriptions to public docs
This commit is contained in:
Tom Moor
2026-04-01 21:56:50 -04:00
committed by GitHub
parent b354d1f5b0
commit bcc5a94070
26 changed files with 1874 additions and 11 deletions
@@ -60,6 +60,19 @@ function InnerPublicAccess(
[share]
);
const handleSubscriptionsChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowSubscriptions: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
@@ -194,6 +207,31 @@ function InnerPublicAccess(
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Email subscriptions")}&nbsp;
<Tooltip
content={t(
"Allow viewers to subscribe and receive email notifications when documents are updated"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Email subscriptions")}
checked={share?.allowSubscriptions ?? true}
onChange={handleSubscriptionsChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
@@ -70,6 +70,19 @@ function PublicAccess(
[share]
);
const handleSubscriptionsChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowSubscriptions: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
@@ -238,6 +251,31 @@ function PublicAccess(
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Email subscriptions")}&nbsp;
<Tooltip
content={t(
"Allow viewers to subscribe and receive email notifications when this document is updated"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Email subscriptions")}
checked={share?.allowSubscriptions ?? true}
onChange={handleSubscriptionsChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
+33 -1
View File
@@ -1,9 +1,16 @@
import { observer } from "mobx-react";
import { MoonIcon, SunIcon } from "outline-icons";
import { MoonIcon, SunIcon, SubscribeIcon } from "outline-icons";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import Tooltip from "~/components/Tooltip";
import { ShareSubscribeForm } from "./ShareSubscribeForm";
import useStores from "~/hooks/useStores";
import { Theme } from "~/stores/UiStore";
@@ -42,3 +49,28 @@ export const AppearanceAction = observer(() => {
</Action>
);
});
export function SubscribeAction({ shareId }: { shareId: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Action>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip content={t("Subscribe to updates")} placement="bottom">
<PopoverTrigger>
<Button
icon={<SubscribeIcon />}
aria-label={t("Subscribe to updates")}
neutral
borderOnHover
/>
</PopoverTrigger>
</Tooltip>
<PopoverContent side="bottom" align="end" width={340}>
<ShareSubscribeForm shareId={shareId} />
</PopoverContent>
</Popover>
</Action>
);
}
@@ -0,0 +1,101 @@
import type { FormEvent, ChangeEvent } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import { client } from "~/utils/ApiClient";
/**
* Subscribe form content displayed inside the popover.
*/
export function ShareSubscribeForm({ shareId }: { shareId: string }) {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = useCallback(
async (ev: FormEvent) => {
ev.preventDefault();
setStatus("loading");
try {
await client.post("/shares.subscribe", { shareId, email });
setStatus("success");
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : t("Something went wrong.")
);
setStatus("error");
}
},
[shareId, email]
);
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
setEmail(ev.target.value);
if (status === "error") {
setErrorMessage("");
setStatus("idle");
}
},
[status]
);
if (status === "success") {
return (
<FormContainer>
<Text type="tertiary" size="small">
{t("Check your email to confirm your subscription.")}
</Text>
</FormContainer>
);
}
return (
<FormContainer>
<StyledForm onSubmit={handleSubmit}>
<Text as="label" type="tertiary" size="small">
{t("Get notified when this document is updated")}
</Text>
<Flex align="center" gap={8}>
<Input
type="email"
value={email}
onChange={handleChange}
placeholder={t("Email address")}
required
margin={0}
flex
/>
<Button type="submit" disabled={status === "loading"} neutral>
{t("Subscribe")}
</Button>
</Flex>
{status === "error" && <ErrorText>{errorMessage}</ErrorText>}
</StyledForm>
</FormContainer>
);
}
const FormContainer = styled.div`
padding: 4px 0;
`;
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 8px;
`;
const ErrorText = styled.p`
font-size: 13px;
color: ${s("danger")};
margin: 0;
`;
+4
View File
@@ -70,6 +70,10 @@ class Share extends Model implements Searchable {
@observable
allowIndexing: boolean;
@Field
@observable
allowSubscriptions: boolean;
@Field
@observable
showLastUpdated: boolean;
+8 -2
View File
@@ -38,7 +38,10 @@ import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
import {
AppearanceAction,
SubscribeAction,
} from "~/components/Sharing/components/Actions";
import useShare from "@shared/hooks/useShare";
import { type Editor } from "~/editor";
import { ChangesNavigation } from "./ChangesNavigation";
@@ -92,7 +95,7 @@ function DocumentHeader({
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
const { isShare, shareId, sharedTree } = useShare();
const { isShare, shareId, sharedTree, allowSubscriptions } = useShare();
const isMobile = isMobileMedia || size.width < 700;
// We cache this value for as long as the component is mounted so that if you
@@ -210,6 +213,9 @@ function DocumentHeader({
}
actions={
<>
{allowSubscriptions !== false && (
<SubscribeAction shareId={shareId} />
)}
<AppearanceAction />
{can.update && !isEditing ? editAction : <div />}
</>
+4 -4
View File
@@ -1,16 +1,16 @@
import { observer } from "mobx-react";
import { useEffect, useMemo, useRef } from "react";
import type { PublicTeam } from "@shared/types";
import { TOCPosition } from "@shared/types";
import type DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import Branding from "~/components/Branding";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useEffect, useMemo, useRef } from "react";
import { parseDomain } from "@shared/utils/domains";
import useCurrentUser from "~/hooks/useCurrentUser";
import Branding from "~/components/Branding";
import useShare from "@shared/hooks/useShare";
import useQuery from "~/hooks/useQuery";
import useShare from "@shared/hooks/useShare";
import { parseDomain } from "@shared/utils/domains";
type Props = {
document: DocumentModel;
+1
View File
@@ -250,6 +250,7 @@ function SharedScene() {
value={{
shareId,
sharedTree: share.tree,
allowSubscriptions: share.allowSubscriptions,
}}
>
<Helmet>
@@ -0,0 +1,108 @@
import * as React from "react";
import { ShareSubscription } from "@server/models";
import ShareSubscriptionHelper from "@server/models/helpers/ShareSubscriptionHelper";
import type { EmailProps } from "./BaseEmail";
import BaseEmail, { EmailMessageCategory } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
shareSubscriptionId: string;
documentTitle: string;
shareUrl: string;
};
type BeforeSend = {
unsubscribeUrl: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a share subscriber when the shared document is updated.
*/
export default class ShareDocumentUpdatedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend(props: InputProps) {
const subscription = await ShareSubscription.findByPk(
props.shareSubscriptionId
);
if (
!subscription ||
subscription.isUnsubscribed ||
!subscription.isConfirmed
) {
return false;
}
return {
unsubscribeUrl: ShareSubscriptionHelper.unsubscribeUrl(subscription),
};
}
protected unsubscribeUrl({ unsubscribeUrl }: Props) {
return unsubscribeUrl;
}
protected subject({ documentTitle }: Props) {
return this.t(`"{{ documentTitle }}" updated`, { documentTitle });
}
protected preview({ documentTitle }: Props): string {
return this.t('"{{ documentTitle }}" has been updated.', { documentTitle });
}
protected renderAsText({ documentTitle, shareUrl }: Props): string {
return `
${this.t(`"{{ documentTitle }}" updated`, { documentTitle })}
${this.t("A document you subscribed to has been updated.")}
${this.t("View Document")}: ${shareUrl}
`;
}
protected render({ documentTitle, shareUrl, unsubscribeUrl }: Props) {
const documentLink = `${shareUrl}?ref=subscription-email`;
return (
<EmailTemplate
previewText={this.preview({ documentTitle } as Props)}
goToAction={{ url: documentLink, name: this.t("View Document") }}
>
<Header />
<Body>
<Heading>
{this.t(`"{{ documentTitle }}" updated`, { documentTitle })}
</Heading>
<p>
{this.t("A document you subscribed to has been updated.")}{" "}
{this.t("Click below to view the latest version.")}
</p>
<EmptySpace height={10} />
<p>
<Button href={documentLink}>{this.t("View Document")}</Button>
</p>
</Body>
<Footer
unsubscribeUrl={unsubscribeUrl}
unsubscribeText={this.t("Unsubscribe from these emails")}
/>
</EmailTemplate>
);
}
}
@@ -0,0 +1,91 @@
import * as React from "react";
import env from "@server/env";
import type { EmailProps } from "./BaseEmail";
import BaseEmail, { EmailMessageCategory } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
documentTitle: string;
confirmUrl: string;
teamName?: string;
};
/**
* Email sent to confirm a share subscription request.
*/
export default class ShareSubscriptionConfirmEmail extends BaseEmail<Props> {
protected get category() {
return EmailMessageCategory.Authentication;
}
protected subject() {
return this.t("Confirm your subscription");
}
protected preview({ documentTitle }: Props) {
return this.t(
'Confirm your subscription to receive updates when "{{ documentTitle }}" changes.',
{ documentTitle }
);
}
protected renderAsText({
documentTitle,
confirmUrl,
teamName,
}: Props): string {
const appName = teamName ?? env.APP_NAME;
return `
${this.t("Confirm your subscription")}
${this.t(
'You requested to receive email notifications when "{{ documentTitle }}" is updated on {{ appName }}. Please confirm your subscription by following the link below.',
{ documentTitle, appName }
)}
${this.t("Confirm Subscription")}: ${confirmUrl}
${this.t("This link will expire in 24 hours.")}
`;
}
protected render({ documentTitle, confirmUrl, teamName }: Props) {
const appName = teamName ?? env.APP_NAME;
return (
<EmailTemplate previewText={this.preview({ documentTitle } as Props)}>
<Header />
<Body>
<Heading>{this.t("Confirm your subscription")}</Heading>
<p>
{this.t(
'You requested to receive email notifications when "{{ documentTitle }}" is updated on {{ appName }}.',
{ documentTitle, appName }
)}
</p>
<p>
{this.t(
"Please confirm your subscription by clicking the button below."
)}
</p>
<EmptySpace height={5} />
<p>
<Button href={confirmUrl}>{this.t("Confirm Subscription")}</Button>
</p>
<EmptySpace height={5} />
<p>
<em>{this.t("This link will expire in 24 hours.")}</em>
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}
@@ -0,0 +1,90 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable(
"share_subscriptions",
{
id: {
type: Sequelize.UUID,
allowNull: false,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
shareId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "shares",
key: "id",
},
onDelete: "CASCADE",
},
email: {
type: Sequelize.STRING(255),
allowNull: false,
},
emailFingerprint: {
type: Sequelize.STRING(255),
allowNull: false,
},
secret: {
type: Sequelize.STRING(64),
allowNull: false,
},
ipAddress: {
type: Sequelize.STRING(45),
allowNull: true,
},
confirmedAt: {
type: Sequelize.DATE,
allowNull: true,
},
unsubscribedAt: {
type: Sequelize.DATE,
allowNull: true,
},
lastNotifiedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);
await queryInterface.addIndex(
"share_subscriptions",
["shareId", "emailFingerprint"],
{ unique: true, transaction }
);
await queryInterface.addIndex(
"share_subscriptions",
["shareId", "confirmedAt"],
{
where: { unsubscribedAt: null },
transaction,
}
);
await queryInterface.addIndex("share_subscriptions", ["ipAddress"], {
transaction,
});
});
},
async down(queryInterface) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("share_subscriptions", { transaction });
});
},
};
@@ -0,0 +1,15 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("shares", "allowSubscriptions", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("shares", "allowSubscriptions");
},
};
+4
View File
@@ -143,6 +143,10 @@ class Share extends IdModel<
@Column
allowIndexing: boolean;
@Default(true)
@Column
allowSubscriptions: boolean;
@Default(false)
@Column
showLastUpdated: boolean;
+253
View File
@@ -0,0 +1,253 @@
import { randomString } from "@shared/random";
import { buildShare } from "@server/test/factories";
import ShareSubscription from "./ShareSubscription";
describe("ShareSubscription", () => {
describe("generateConfirmToken / generateUnsubscribeToken", () => {
it("should produce deterministic tokens", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const token1 = ShareSubscription.generateConfirmToken(subscription);
const token2 = ShareSubscription.generateConfirmToken(subscription);
expect(token1).toBe(token2);
});
it("should produce different tokens for confirm vs unsubscribe", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const confirmToken = ShareSubscription.generateConfirmToken(subscription);
const unsubscribeToken =
ShareSubscription.generateUnsubscribeToken(subscription);
expect(confirmToken).not.toBe(unsubscribeToken);
});
it("should produce different tokens for different secrets", async () => {
const share = await buildShare();
const sub1 = await ShareSubscription.create({
shareId: share.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const sub2 = await ShareSubscription.create({
shareId: share.id,
email: "test2@example.com",
emailFingerprint: "test2@example.com",
secret: randomString(32),
});
expect(ShareSubscription.generateConfirmToken(sub1)).not.toBe(
ShareSubscription.generateConfirmToken(sub2)
);
});
});
describe("normalizeEmailFingerprint", () => {
it("should return a hex hash string", () => {
const fp =
ShareSubscription.normalizeEmailFingerprint("user@example.com");
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
it("should treat different cases as equivalent", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("User@Example.COM")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should remove dots from Gmail local part", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("first.last@gmail.com")
).toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
);
});
it("should preserve dots for non-Gmail domains", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("first.last@example.com")
).not.toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@example.com")
);
});
it("should strip +alias from local part", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user+tag@example.com")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should handle dots and +alias together for Gmail", () => {
expect(
ShareSubscription.normalizeEmailFingerprint(
"first.last+newsletter@gmail.com"
)
).toBe(
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
);
});
it("should not remove dots from domain", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user@sub.example.com")
).not.toBe(
ShareSubscription.normalizeEmailFingerprint("user@subexample.com")
);
});
it("should trim whitespace", () => {
expect(
ShareSubscription.normalizeEmailFingerprint(" user@example.com ")
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
});
it("should treat gmail.com and googlemail.com as equivalent", () => {
const gmail = ShareSubscription.normalizeEmailFingerprint(
"first.last+tag@gmail.com"
);
const googlemail = ShareSubscription.normalizeEmailFingerprint(
"first.last+tag@googlemail.com"
);
expect(gmail).toBe(googlemail);
});
it("should not alter other domains ending in googlemail.com", () => {
expect(
ShareSubscription.normalizeEmailFingerprint("user@notgooglemail.com")
).not.toBe(ShareSubscription.normalizeEmailFingerprint("user@gmail.com"));
});
it("should handle email without @ gracefully", () => {
const fp = ShareSubscription.normalizeEmailFingerprint("invalid");
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
it("should strip null bytes to prevent injection bypasses", () => {
const normal =
ShareSubscription.normalizeEmailFingerprint("user@example.com");
const withNull =
ShareSubscription.normalizeEmailFingerprint("user\0@example.com");
expect(withNull).toBe(normal);
});
});
describe("checkIPLimit", () => {
beforeEach(async () => {
await ShareSubscription.destroy({ where: {}, force: true });
});
it("should allow up to 3 unique emails from the same IP", async () => {
const share = await buildShare();
const ip = "192.168.1.1";
for (let i = 0; i < 3; i++) {
await ShareSubscription.create({
shareId: share.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
ipAddress: ip,
});
}
await expect(
ShareSubscription.create({
shareId: share.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
ipAddress: ip,
})
).rejects.toThrow("limit");
});
it("should not count subscriptions from different IPs", async () => {
const share = await buildShare();
for (let i = 0; i < 3; i++) {
await ShareSubscription.create({
shareId: share.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
ipAddress: `10.0.0.${i}`,
});
}
await expect(
ShareSubscription.create({
shareId: share.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
ipAddress: "10.0.0.3",
})
).resolves.toBeDefined();
});
it("should not count duplicate fingerprints from the same IP", async () => {
const share1 = await buildShare();
const share2 = await buildShare();
const ip = "192.168.2.1";
// Same fingerprint across different shares — should count as 1
for (let i = 0; i < 3; i++) {
const share = await buildShare();
await ShareSubscription.create({
shareId: share.id,
email: "same@example.com",
emailFingerprint: "same@example.com",
secret: randomString(32),
ipAddress: ip,
});
}
// 2 more unique fingerprints — total distinct = 3
for (let i = 0; i < 2; i++) {
await ShareSubscription.create({
shareId: share1.id,
email: `other${i}@example.com`,
emailFingerprint: `other${i}@example.com`,
secret: randomString(32),
ipAddress: ip,
});
}
// 4th unique fingerprint should be blocked
await expect(
ShareSubscription.create({
shareId: share2.id,
email: "blocked@example.com",
emailFingerprint: "blocked@example.com",
secret: randomString(32),
ipAddress: ip,
})
).rejects.toThrow("limit");
});
it("should skip the check if ipAddress is null", async () => {
const share = await buildShare();
for (let i = 0; i < 6; i++) {
await ShareSubscription.create({
shareId: share.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
});
}
});
});
});
+165
View File
@@ -0,0 +1,165 @@
import crypto from "node:crypto";
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { type SaveOptions, Op } from "sequelize";
import {
BeforeCreate,
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
Scopes,
} from "sequelize-typescript";
import { subHours } from "date-fns";
import { ValidationError } from "@server/errors";
import Share from "./Share";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Scopes(() => ({
active: {
where: {
confirmedAt: { [Op.not]: null },
unsubscribedAt: null,
},
},
}))
@Table({ tableName: "share_subscriptions", modelName: "share_subscription" })
@Fix
class ShareSubscription extends IdModel<
InferAttributes<ShareSubscription>,
Partial<InferCreationAttributes<ShareSubscription>>
> {
@BelongsTo(() => Share, "shareId")
share: Share;
@ForeignKey(() => Share)
@Column(DataType.UUID)
shareId: string;
@Column(DataType.STRING)
email: string;
@Column(DataType.STRING)
emailFingerprint: string;
@Column(DataType.STRING)
secret: string;
@Column(DataType.STRING(45))
ipAddress: string | null;
@Column(DataType.DATE)
confirmedAt: Date | null;
@Column(DataType.DATE)
unsubscribedAt: Date | null;
@Column(DataType.DATE)
lastNotifiedAt: Date | null;
/** Maximum number of unique email subscriptions allowed per IP address. */
static maxSubscriptionsPerIP = 3;
@BeforeCreate
static async checkIPLimit(
model: ShareSubscription,
options: SaveOptions<ShareSubscription>
) {
if (!model.ipAddress) {
return;
}
const results = await this.findAll({
attributes: ["emailFingerprint"],
where: { ipAddress: model.ipAddress },
group: ["emailFingerprint"],
transaction: options.transaction,
});
const count = results.length;
if (count >= this.maxSubscriptionsPerIP) {
throw ValidationError(`You have reached the limit of subscriptions`);
}
}
/**
* Whether this subscription has been confirmed via email.
*/
get isConfirmed(): boolean {
return !!this.confirmedAt;
}
/**
* Whether this subscription has been unsubscribed.
*/
get isUnsubscribed(): boolean {
return !!this.unsubscribedAt;
}
/**
* Whether the confirmation token has expired (24-hour window from last update).
*/
get isTokenExpired(): boolean {
return this.updatedAt < subHours(new Date(), 24);
}
/**
* Normalize an email address into a fingerprint for uniqueness comparison.
* Lowercases, removes dots from local part, and strips +alias suffixes.
*
* @param email The email address to normalize.
* @returns The normalized email fingerprint.
*/
static normalizeEmailFingerprint(email: string): string {
// Strip null bytes to prevent injection bypasses
// eslint-disable-next-line no-control-regex
const lower = email.replace(/\0/g, "").toLowerCase().trim();
const [localPart, domain] = lower.split("@");
if (!localPart || !domain) {
return crypto.createHash("sha256").update(lower).digest("hex");
}
const withoutPlus = localPart.split("+")[0];
// Normalize googlemail.com to gmail.com as they are the same service
const normalizedDomain = domain === "googlemail.com" ? "gmail.com" : domain;
// Gmail ignores dots in the local part; other providers treat them as significant
const normalizedLocal =
normalizedDomain === "gmail.com"
? withoutPlus.replace(/\./g, "")
: withoutPlus;
const normalized = `${normalizedLocal}@${normalizedDomain}`;
return crypto.createHash("sha256").update(normalized).digest("hex");
}
/**
* Generate an HMAC token for confirming this subscription.
*
* @param subscription The subscription to generate a token for.
* @returns The confirmation token as a hex string.
*/
static generateConfirmToken(subscription: ShareSubscription): string {
return crypto
.createHmac("sha256", subscription.secret)
.update(`${subscription.shareId}:${subscription.email}:confirm`)
.digest("hex");
}
/**
* Generate an HMAC token for unsubscribing from this subscription.
*
* @param subscription The subscription to generate a token for.
* @returns The unsubscribe token as a hex string.
*/
static generateUnsubscribeToken(subscription: ShareSubscription): string {
return crypto
.createHmac("sha256", subscription.secret)
.update(`${subscription.shareId}:${subscription.email}:unsubscribe`)
.digest("hex");
}
}
export default ShareSubscription;
@@ -0,0 +1,38 @@
import queryString from "query-string";
import env from "@server/env";
import ShareSubscription from "@server/models/ShareSubscription";
/**
* Helper class for working with share subscriptions.
*/
export default class ShareSubscriptionHelper {
/**
* Get the confirmation URL for a share subscription.
*
* @param subscription The share subscription to confirm.
* @returns The confirmation URL.
*/
public static confirmUrl(subscription: ShareSubscription): string {
const token = ShareSubscription.generateConfirmToken(subscription);
return `${env.URL}/api/shares.confirmSubscription?${queryString.stringify({
id: subscription.id,
token,
})}`;
}
/**
* Get the unsubscribe URL for a share subscription.
*
* @param subscription The share subscription to unsubscribe.
* @returns The unsubscribe URL.
*/
public static unsubscribeUrl(subscription: ShareSubscription): string {
const token = ShareSubscription.generateUnsubscribeToken(subscription);
return `${env.URL}/api/shares.unsubscribe?${queryString.stringify({
id: subscription.id,
token,
})}`;
}
}
+2
View File
@@ -52,6 +52,8 @@ export { default as SearchQuery } from "./SearchQuery";
export { default as Share } from "./Share";
export { default as ShareSubscription } from "./ShareSubscription";
export { default as Star } from "./Star";
export { default as Team } from "./Team";
+1
View File
@@ -16,6 +16,7 @@ export default function presentShare(share: Share, isAdmin = false) {
createdBy: presentUser(share.user),
includeChildDocuments: share.includeChildDocuments,
allowIndexing: share.allowIndexing,
allowSubscriptions: share.allowSubscriptions,
showLastUpdated: share.showLastUpdated,
showTOC: share.showTOC,
lastAccessedAt: share.lastAccessedAt || undefined,
@@ -19,6 +19,7 @@ import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotifica
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask";
import ShareSubscriptionNotificationsTask from "../tasks/ShareSubscriptionNotificationsTask";
import BaseProcessor from "./BaseProcessor";
export default class NotificationsProcessor extends BaseProcessor {
@@ -86,6 +87,7 @@ export default class NotificationsProcessor extends BaseProcessor {
async revisionCreated(event: RevisionEvent) {
await new RevisionCreatedNotificationsTask().schedule(event);
await new ShareSubscriptionNotificationsTask().schedule(event);
}
async collectionCreated(event: CollectionEvent) {
@@ -0,0 +1,277 @@
import { subHours } from "date-fns";
import { randomString } from "@shared/random";
import ShareDocumentUpdatedEmail from "@server/emails/templates/ShareDocumentUpdatedEmail";
import { ShareSubscription } from "@server/models";
import { buildDocument, buildShare } from "@server/test/factories";
import ShareSubscriptionNotificationsTask from "./ShareSubscriptionNotificationsTask";
const ip = "127.0.0.1";
beforeEach(() => {
jest.restoreAllMocks();
});
describe("ShareSubscriptionNotificationsTask", () => {
it("should send email to confirmed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).toHaveBeenCalledTimes(1);
});
it("should not send email to unconfirmed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should not send email to unsubscribed subscriber", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
unsubscribedAt: new Date(),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should throttle notifications to once per 6 hours", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
lastNotifiedAt: subHours(new Date(), 3),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should send if last notified more than 6 hours ago", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
lastNotifiedAt: subHours(new Date(), 7),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).toHaveBeenCalledTimes(1);
});
it("should not send for unpublished shares", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
published: false,
});
await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
it("should update lastNotifiedAt after sending", async () => {
jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
expect(subscription.lastNotifiedAt).toBeNull();
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
await subscription.reload();
expect(subscription.lastNotifiedAt).not.toBeNull();
});
it("should send to multiple subscribers", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
email: "sub1@example.com",
emailFingerprint: "sub1@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
await ShareSubscription.create({
shareId: share.id,
email: "sub2@example.com",
emailFingerprint: "sub2@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).toHaveBeenCalledTimes(2);
});
it("should not send if document has no shares", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const document = await buildDocument();
const task = new ShareSubscriptionNotificationsTask();
await task.perform({
name: "revisions.create",
documentId: document.id,
teamId: document.teamId,
actorId: document.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
import { subHours } from "date-fns";
import { Op } from "sequelize";
import ShareDocumentUpdatedEmail from "@server/emails/templates/ShareDocumentUpdatedEmail";
import Logger from "@server/logging/Logger";
import { Document, Share, ShareSubscription } from "@server/models";
import type { RevisionEvent } from "@server/types";
import { BaseTask, TaskPriority } from "./base/BaseTask";
export default class ShareSubscriptionNotificationsTask extends BaseTask<RevisionEvent> {
public async perform(event: RevisionEvent) {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
// Find all published, non-revoked shares for this document
const shares = await Share.findAll({
where: {
published: true,
revokedAt: null,
[Op.or]: [
{ documentId: document.id },
...(document.collectionId
? [
{
collectionId: document.collectionId,
includeChildDocuments: true,
},
]
: []),
],
},
});
if (!shares.length) {
return;
}
for (const share of shares) {
if (!share.allowSubscriptions) {
continue;
}
const subscriptions = await ShareSubscription.scope("active").findAll({
where: { shareId: share.id },
});
for (const subscription of subscriptions) {
// Throttle: only one notification per 6 hours
if (
subscription.lastNotifiedAt &&
subscription.lastNotifiedAt > subHours(new Date(), 6)
) {
Logger.info(
"processor",
`suppressing share subscription notification to ${subscription.id} as recently notified`
);
continue;
}
const baseShareUrl = share.canonicalUrl;
const shareUrl =
share.collectionId && document.path
? `${baseShareUrl.replace(/\/$/, "")}${document.path}`
: baseShareUrl;
new ShareDocumentUpdatedEmail({
to: subscription.email,
shareSubscriptionId: subscription.id,
documentTitle: document.titleWithDefault,
shareUrl,
}).schedule();
subscription.lastNotifiedAt = new Date();
await subscription.save();
}
}
}
public get options() {
return {
priority: TaskPriority.Background,
};
}
}
+33
View File
@@ -53,6 +53,7 @@ export const SharesUpdateSchema = BaseSchema.extend({
includeChildDocuments: z.boolean().optional(),
published: z.boolean().optional(),
allowIndexing: z.boolean().optional(),
allowSubscriptions: z.boolean().optional(),
showLastUpdated: z.boolean().optional(),
showTOC: z.boolean().optional(),
urlId: z
@@ -73,6 +74,7 @@ export const SharesCreateSchema = BaseSchema.extend({
documentId: zodIdType().optional(),
published: z.boolean().prefault(false),
allowIndexing: z.boolean().optional(),
allowSubscriptions: z.boolean().optional(),
showLastUpdated: z.boolean().optional(),
showTOC: z.boolean().optional(),
urlId: z
@@ -105,3 +107,34 @@ export const SharesSitemapSchema = BaseSchema.extend({
});
export type SharesSitemapReq = z.infer<typeof SharesSitemapSchema>;
export const SharesSubscribeSchema = BaseSchema.extend({
body: z.object({
shareId: z.string(),
email: z.string().email(),
}),
});
export type SharesSubscribeReq = z.infer<typeof SharesSubscribeSchema>;
export const SharesConfirmSubscriptionSchema = BaseSchema.extend({
query: z.object({
id: z.uuid(),
token: z.string(),
follow: z.string().optional(),
}),
});
export type SharesConfirmSubscriptionReq = z.infer<
typeof SharesConfirmSubscriptionSchema
>;
export const SharesUnsubscribeSchema = BaseSchema.extend({
query: z.object({
id: z.uuid(),
token: z.string(),
follow: z.string().optional(),
}),
});
export type SharesUnsubscribeReq = z.infer<typeof SharesUnsubscribeSchema>;
+265 -1
View File
@@ -1,6 +1,8 @@
import queryString from "query-string";
import { randomString } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { createContext } from "@server/context";
import { UserMembership, Share } from "@server/models";
import { UserMembership, Share, ShareSubscription } from "@server/models";
import {
buildUser,
buildDocument,
@@ -1153,3 +1155,265 @@ describe("#shares.revoke", () => {
expect(res.status).toEqual(403);
});
});
describe("#shares.subscribe", () => {
it("should create a subscription for a published share", async () => {
const share = await buildShare();
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "subscriber@example.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const subscription = await ShareSubscription.findOne({
where: { shareId: share.id },
});
expect(subscription).not.toBeNull();
expect(subscription!.email).toBe("subscriber@example.com");
expect(subscription!.confirmedAt).toBeNull();
});
it("should normalize email fingerprint on create", async () => {
const share = await buildShare();
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "First.Last+tag@Example.com",
},
});
const subscription = await ShareSubscription.findOne({
where: { shareId: share.id },
});
expect(subscription!.emailFingerprint).toBe(
ShareSubscription.normalizeEmailFingerprint("First.Last+tag@Example.com")
);
});
it("should not create duplicate subscriptions for same fingerprint", async () => {
const share = await buildShare();
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "user@gmail.com",
},
});
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "u.s.e.r@gmail.com",
},
});
const count = await ShareSubscription.count({
where: { shareId: share.id },
});
expect(count).toBe(1);
});
it("should silently succeed for already confirmed subscription", async () => {
const share = await buildShare();
await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint:
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
secret: randomString(32),
confirmedAt: new Date(),
});
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "user@example.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
});
it("should allow re-subscribing after unsubscribe", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint:
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
secret: randomString(32),
confirmedAt: new Date(),
unsubscribedAt: new Date(),
});
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "user@example.com",
},
});
expect(res.status).toEqual(200);
await subscription.reload();
expect(subscription.unsubscribedAt).toBeNull();
expect(subscription.confirmedAt).toBeNull();
});
it("should fail for unpublished share", async () => {
const share = await buildShare({ published: false });
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "user@example.com",
},
});
expect(res.status).toEqual(404);
});
it("should fail with invalid email", async () => {
const share = await buildShare();
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
email: "not-an-email",
},
});
expect(res.status).toEqual(400);
});
});
describe("#shares.confirmSubscription", () => {
it("should confirm a subscription with valid token", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
});
const token = ShareSubscription.generateConfirmToken(subscription);
const res = await server.get(
`/api/shares.confirmSubscription?${queryString.stringify({
id: subscription.id,
token,
follow: "true",
})}`,
{ redirect: "manual" }
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain("notice=subscribed");
await subscription.reload();
expect(subscription.confirmedAt).not.toBeNull();
});
it("should reject an invalid token", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
});
const res = await server.get(
`/api/shares.confirmSubscription?${queryString.stringify({
id: subscription.id,
token: "invalid-token",
follow: "true",
})}`,
{ redirect: "manual" }
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain("notice=invalid-auth");
await subscription.reload();
expect(subscription.confirmedAt).toBeNull();
});
it("should reject an expired token", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
});
// Force updatedAt to 25 hours ago so the token is expired
const expiredDate = new Date(Date.now() - 25 * 60 * 60 * 1000);
await ShareSubscription.update(
{ createdAt: expiredDate, updatedAt: expiredDate },
{ where: { id: subscription.id }, silent: true }
);
await subscription.reload();
const token = ShareSubscription.generateConfirmToken(subscription);
const res = await server.get(
`/api/shares.confirmSubscription?${queryString.stringify({
id: subscription.id,
token,
follow: "true",
})}`,
{ redirect: "manual" }
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain("notice=expired-token");
});
});
describe("#shares.unsubscribe", () => {
it("should unsubscribe with valid token", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
const token = ShareSubscription.generateUnsubscribeToken(subscription);
const res = await server.get(
`/api/shares.unsubscribe?${queryString.stringify({
id: subscription.id,
token,
follow: "true",
})}`,
{ redirect: "manual" }
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain("notice=unsubscribed");
await subscription.reload();
expect(subscription.unsubscribedAt).not.toBeNull();
});
it("should reject an invalid token", async () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
confirmedAt: new Date(),
});
const res = await server.get(
`/api/shares.unsubscribe?${queryString.stringify({
id: subscription.id,
token: "invalid-token",
follow: "true",
})}`,
{ redirect: "manual" }
);
expect(res.status).toEqual(302);
expect(res.headers.get("location")).toContain("notice=invalid-auth");
await subscription.reload();
expect(subscription.unsubscribedAt).toBeNull();
});
});
+198 -2
View File
@@ -2,13 +2,28 @@ import Router from "koa-router";
import isUndefined from "lodash/isUndefined";
import type { FindOptions, WhereAttributeHash, WhereOptions } from "sequelize";
import { Op } from "sequelize";
import { subMinutes } from "date-fns";
import { randomString } from "@shared/random";
import { TeamPreference } from "@shared/types";
import { AuthenticationError, NotFoundError } from "@server/errors";
import {
AuthenticationError,
InvalidRequestError,
NotFoundError,
} from "@server/errors";
import ShareSubscriptionConfirmEmail from "@server/emails/templates/ShareSubscriptionConfirmEmail";
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 { Document, User, Share, Team, Collection } from "@server/models";
import {
Document,
User,
Share,
Team,
Collection,
ShareSubscription,
} from "@server/models";
import ShareSubscriptionHelper from "@server/models/helpers/ShareSubscriptionHelper";
import { authorize, cannot } from "@server/policies";
import {
presentShare,
@@ -28,6 +43,8 @@ import {
loadShareWithParent,
} from "@server/commands/shareLoader";
import shareDomains from "@server/middlewares/shareDomains";
import env from "@server/env";
import { safeEqual } from "@server/utils/crypto";
const router = new Router();
@@ -245,6 +262,7 @@ router.post(
urlId,
includeChildDocuments,
allowIndexing,
allowSubscriptions,
showLastUpdated,
showTOC,
} = ctx.input.body;
@@ -282,6 +300,7 @@ router.post(
published,
includeChildDocuments,
allowIndexing,
allowSubscriptions,
showLastUpdated,
showTOC,
urlId,
@@ -312,6 +331,7 @@ router.post(
published,
urlId,
allowIndexing,
allowSubscriptions,
showLastUpdated,
showTOC,
} = ctx.input.body;
@@ -344,6 +364,9 @@ router.post(
if (allowIndexing !== undefined) {
share.allowIndexing = allowIndexing;
}
if (allowSubscriptions !== undefined) {
share.allowSubscriptions = allowSubscriptions;
}
if (showLastUpdated !== undefined) {
share.showLastUpdated = showLastUpdated;
}
@@ -412,4 +435,177 @@ router.get(
}
);
router.post(
"shares.subscribe",
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.SharesSubscribeSchema),
transaction(),
async (ctx: APIContext<T.SharesSubscribeReq>) => {
const { shareId, email } = ctx.input.body;
const { transaction } = ctx.state;
// Validate the share exists and is published
const { share, document } = await loadPublicShare({ id: shareId });
if (!share.allowSubscriptions) {
throw InvalidRequestError("Subscriptions are not enabled for this share");
}
const emailFingerprint = ShareSubscription.normalizeEmailFingerprint(email);
const existing = await ShareSubscription.findOne({
where: { shareId: share.id, emailFingerprint },
transaction,
lock: transaction.LOCK.UPDATE,
});
let subscription: ShareSubscription;
if (existing) {
// Already confirmed and active — return success silently
if (existing.isConfirmed && !existing.isUnsubscribed) {
ctx.body = { success: true };
return;
}
// Unsubscribed — allow re-subscribe with new confirmation
if (existing.isUnsubscribed) {
existing.unsubscribedAt = null;
existing.confirmedAt = null;
existing.lastNotifiedAt = null;
existing.secret = randomString(32);
existing.email = email;
await existing.save({ transaction });
} else if (existing.createdAt > subMinutes(new Date(), 60)) {
// Recently created, not yet confirmed — don't spam
ctx.body = { success: true };
return;
} else {
// Expired or stale unconfirmed — regenerate
existing.secret = randomString(32);
existing.email = email;
await existing.save({ transaction });
}
subscription = existing;
} else {
subscription = await ShareSubscription.create({
shareId: share.id,
email,
emailFingerprint,
secret: randomString(32),
ipAddress: ctx.request.ip,
});
}
const confirmUrl = ShareSubscriptionHelper.confirmUrl(subscription);
const usePublicBranding =
share.team?.getPreference(TeamPreference.PublicBranding) ?? false;
new ShareSubscriptionConfirmEmail({
to: email,
documentTitle:
document?.titleWithDefault ??
share.document?.title ??
share.collection?.name ??
"",
confirmUrl,
teamName: usePublicBranding ? share.team?.name : undefined,
}).schedule();
ctx.body = { success: true };
}
);
router.get(
"shares.confirmSubscription",
rateLimiter(RateLimiterStrategy.TenPerMinute),
validate(T.SharesConfirmSubscriptionSchema),
transaction(),
async (ctx: APIContext<T.SharesConfirmSubscriptionReq>) => {
const { id, token, follow } = ctx.input.query;
const { transaction } = ctx.state;
// Anti-prefetch: prevent email clients from pre-fetching the link
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
const subscription = await ShareSubscription.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!subscription || subscription.isUnsubscribed) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
const share = await Share.findByPk(subscription.shareId);
if (!share?.allowSubscriptions) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
const expectedToken = ShareSubscription.generateConfirmToken(subscription);
if (!safeEqual(token, expectedToken)) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
if (subscription.isTokenExpired && !subscription.isConfirmed) {
ctx.redirect(`${env.URL}?notice=expired-token`);
return;
}
subscription.confirmedAt = new Date();
await subscription.save({ transaction });
const shareUrl = share?.canonicalUrl ?? env.URL;
ctx.redirect(`${shareUrl}?notice=subscribed`);
}
);
router.get(
"shares.unsubscribe",
rateLimiter(RateLimiterStrategy.TenPerMinute),
validate(T.SharesUnsubscribeSchema),
transaction(),
async (ctx: APIContext<T.SharesUnsubscribeReq>) => {
const { id, token, follow } = ctx.input.query;
const { transaction } = ctx.state;
// Anti-prefetch: prevent email clients from pre-fetching the link
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
const subscription = await ShareSubscription.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!subscription) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
const expectedToken =
ShareSubscription.generateUnsubscribeToken(subscription);
if (!safeEqual(token, expectedToken)) {
ctx.redirect(`${env.URL}?notice=invalid-auth`);
return;
}
subscription.unsubscribedAt = new Date();
await subscription.save({ transaction });
const share = await Share.findByPk(subscription.shareId);
const shareUrl = share?.canonicalUrl ?? env.URL;
ctx.redirect(`${shareUrl}?notice=unsubscribed`);
}
);
export default router;
+1
View File
@@ -4,6 +4,7 @@ import * as React from "react";
type ShareContextType = {
shareId?: string;
sharedTree?: NavigationNode;
allowSubscriptions?: boolean;
};
export const ShareContext = React.createContext<ShareContextType>({});
+19 -1
View File
@@ -441,6 +441,8 @@
"Publish to internet": "Publish to internet",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Email subscriptions": "Email subscriptions",
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
"Show last modified": "Show last modified",
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
"Show table of contents": "Show table of contents",
@@ -454,8 +456,13 @@
"{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} people and {{ count2 }} groups added to the collection",
"Switch to dark": "Switch to dark",
"Switch to light": "Switch to light",
"Subscribe to updates": "Subscribe to updates",
"Add": "Add",
"Add or invite": "Add or invite",
"Something went wrong.": "Something went wrong.",
"Check your email to confirm your subscription.": "Check your email to confirm your subscription.",
"Get notified when this document is updated": "Get notified when this document is updated",
"Email address": "Email address",
"Viewer": "Viewer",
"Editor": "Editor",
"Suggestions for invitation": "Suggestions for invitation",
@@ -480,6 +487,7 @@
"Leave": "Leave",
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
"Allow viewers to subscribe and receive email notifications when this document is updated": "Allow viewers to subscribe and receive email notifications when this document is updated",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
"{{ count }} people added to the document": "{{ count }} people added to the document",
@@ -1331,7 +1339,6 @@
"Photo": "Photo",
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "This could be your real name, or a nickname — however youd like people to refer to you.",
"Email address": "Email address",
"Members and guests": "Members and guests",
"No one": "No one",
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
@@ -1687,6 +1694,17 @@
"This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.": "This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.",
"We only send a reminder once.": "We only send a reminder once.",
"If you haven't signed up yet, you can do so here": "If you haven't signed up yet, you can do so here",
"\"{{ documentTitle }}\" updated": "\"{{ documentTitle }}\" updated",
"\"{{ documentTitle }}\" has been updated.": "\"{{ documentTitle }}\" has been updated.",
"A document you subscribed to has been updated.": "A document you subscribed to has been updated.",
"Click below to view the latest version.": "Click below to view the latest version.",
"Confirm your subscription": "Confirm your subscription",
"Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.": "Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.",
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.": "You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.",
"Confirm Subscription": "Confirm Subscription",
"This link will expire in 24 hours.": "This link will expire in 24 hours.",
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.": "You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.",
"Please confirm your subscription by clicking the button below.": "Please confirm your subscription by clicking the button below.",
"Magic signin link": "Magic signin link",
"Sign in verification code": "Sign in verification code",
"Heres your link to signin to {{ appName }}.": "Heres your link to signin to {{ appName }}.",