feat(ee): send blocked access email notifications

This commit is contained in:
Marc Seitz
2025-07-25 19:09:57 +02:00
parent 43fe1ce52c
commit b04e2784ad
8 changed files with 256 additions and 193 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { ItemType, Link, LinkAudienceType } from "@prisma/client";
@@ -27,7 +28,6 @@ import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
import { validateEmail } from "@/lib/utils/validate-email";
import { sendBlockedEmailAttemptNotification } from "@/lib/emails/send-blocked-email-attempt";
export async function POST(request: NextRequest) {
try {
@@ -289,11 +289,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
try {
await notifyBlockedAttempt(link, email);
} catch (e) {
console.error(e);
}
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -306,6 +303,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -322,11 +321,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
try {
await notifyBlockedAttempt(link, email);
} catch (e) {
console.error(e);
}
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -369,6 +365,7 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -1025,59 +1022,3 @@ export async function POST(request: NextRequest) {
);
}
}
type PartialLink = Partial<Link>
export async function notifyBlockedAttempt(link: PartialLink, email: string) {
if (!link) return;
const users = await prisma.userTeam.findMany({
where: {
role: { in: ["ADMIN", "MANAGER"] },
status: "ACTIVE",
teamId: link.teamId!,
},
select: {
user: { select: { email: true } },
},
});
const adminEmails = users.map((u) => u.user?.email).filter((e): e is string => !!e);
let ownerEmail: string | undefined;
let resourceType: "dataroom" | "document" = "dataroom";
let resourceName = "Dataroom";
if (link.documentId) {
resourceType = "document";
const doc = await prisma.document.findUnique({ where: { id: link.documentId }, select: { name: true, ownerId: true } });
resourceName = doc?.name || "Document";
if (doc?.ownerId) {
const owner = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: doc.ownerId,
teamId: link.teamId!,
},
status: "ACTIVE",
},
select: { user: { select: { email: true } } },
});
ownerEmail = owner?.user?.email || undefined;
}
} else if (link.dataroomId) {
resourceType = "dataroom";
resourceName = (await prisma.dataroom.findUnique({ where: { id: link.dataroomId }, select: { name: true } }))?.name || "Dataroom";
}
let cc = adminEmails.slice(1);
const to = ownerEmail || adminEmails[0] || "";
if (ownerEmail && !cc.includes(ownerEmail) && ownerEmail !== to) {
cc = [ownerEmail, ...cc];
}
if (to) {
await sendBlockedEmailAttemptNotification({
to,
cc,
blockedEmail: email!,
linkName: link?.name || `Link #${link.id?.slice(-5)}`,
resourceName,
resourceType,
});
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
// Import authOptions directly from the source
import { authOptions } from "@/pages/api/auth/[...nextauth]";
@@ -24,7 +25,6 @@ import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
import { validateEmail } from "@/lib/utils/validate-email";
import { notifyBlockedAttempt } from "../views-dataroom/route";
export async function POST(request: NextRequest) {
try {
@@ -225,11 +225,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
try {
await notifyBlockedAttempt(link, email);
} catch (e) {
console.error(e);
}
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -242,6 +239,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -258,11 +257,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
try {
await notifyBlockedAttempt(link, email);
} catch (e) {
console.error(e);
}
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },

View File

@@ -1,74 +0,0 @@
import React from "react";
import {
Body,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType = "document",
locationString,
}: {
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
locationString?: string;
}) {
return (
<Html>
<Head />
<Preview>Blocked email attempted to access your {resourceType}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
Blocked Email Attempted Access
</Text>
<Text className="text-sm leading-6 text-black">
The blocked email <span className="font-semibold">{blockedEmail}</span> just attempted to access your {resourceType} <span className="font-semibold">{resourceName}</span> from the link <span className="font-semibold">{linkName}</span>.
{locationString ? (
<span>
{" "}
in <span className="font-semibold">{locationString}</span>
</span>
) : null}
</Text>
<Text className="text-sm leading-6 text-black">
This email is on your block list and was denied access. No further action is required.
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} {" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}

View File

@@ -0,0 +1,108 @@
import React from "react";
import {
Body,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType = "document",
timestamp,
locationString,
accessType,
}: {
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
timestamp?: string;
locationString?: string;
accessType?: "global" | "allow" | "deny";
}) {
const accessTypeTexts = {
global:
"This email is on your global block list and was denied access. No further action is required.",
allow:
"This email is not on your link's allow list and was denied access. No further action is required.",
deny: "This email is on your link's block list and was denied access. No further action is required.",
default: "This email was denied access. No further action is required.",
};
const accessTypeText = accessType
? accessTypeTexts[accessType]
: accessTypeTexts.default;
return (
<Html>
<Head />
<Preview>Blocked email attempted to access your {resourceType}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
Blocked Email Attempted Access
</Text>
<Text className="text-sm leading-6 text-black">
A blocked email attempted to access your {resourceType}:
</Text>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Email address:</span>{" "}
{blockedEmail}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Time:</span>{" "}
{timestamp || new Date().toLocaleString()}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">
{resourceType === "dataroom" ? "Dataroom" : "Document"}{" "}
name:
</span>{" "}
{resourceName}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Link name:</span> {linkName}
</li>
</ul>
</Text>
<Text className="mt-4 text-sm leading-6 text-black">
{accessTypeText}
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
Papermark, Inc.
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}

View File

@@ -0,0 +1 @@
export { reportDeniedAccessAttempt } from "./lib/report-denied-access-attempt";

View File

@@ -0,0 +1,84 @@
import { Link } from "@prisma/client";
import prisma from "@/lib/prisma";
import { sendBlockedEmailAttemptNotification } from "./send-blocked-email-attempt";
export async function reportDeniedAccessAttempt(
link: Partial<Link>,
email: string,
accessType: "global" | "allow" | "deny" = "global",
) {
if (!link) return;
// Get all admin and manager emails
const users = await prisma.userTeam.findMany({
where: {
role: { in: ["ADMIN", "MANAGER"] },
status: "ACTIVE",
teamId: link.teamId!,
},
select: {
user: { select: { email: true } },
},
});
const adminManagerEmails = users
.map((u) => u.user?.email)
.filter((e): e is string => !!e);
// Get resource info and owner email
let resourceType: "dataroom" | "document" = "dataroom";
let resourceName = "Dataroom";
let ownerEmail: string | undefined;
if (link.documentId) {
resourceType = "document";
const document = await prisma.document.findUnique({
where: { id: link.documentId },
select: { name: true, ownerId: true },
});
resourceName = document?.name || "Document";
if (document?.ownerId) {
const owner = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: document.ownerId,
teamId: link.teamId!,
},
status: "ACTIVE",
},
select: { user: { select: { email: true } } },
});
ownerEmail = owner?.user?.email || undefined;
}
} else if (link.dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: link.dataroomId },
select: { name: true },
});
resourceName = dataroom?.name || "Dataroom";
}
// Combine all recipients and remove duplicates
const allRecipients = [...adminManagerEmails];
if (ownerEmail && !allRecipients.includes(ownerEmail)) {
allRecipients.push(ownerEmail);
}
// Send email to all recipients
if (allRecipients.length > 0) {
const [to, ...cc] = allRecipients;
await sendBlockedEmailAttemptNotification({
to,
cc: cc.length > 0 ? cc : undefined,
blockedEmail: email,
linkName: link.name || `Link #${link.id?.slice(-5)}`,
resourceName,
resourceType,
timestamp: new Date().toLocaleString(),
accessType,
});
}
}

View File

@@ -0,0 +1,48 @@
import { sendEmail } from "@/lib/resend";
import BlockedEmailAttempt from "../components/blocked-email-attempt";
export const sendBlockedEmailAttemptNotification = async ({
to,
cc,
blockedEmail,
linkName,
resourceName,
resourceType = "document",
timestamp,
locationString,
accessType,
}: {
to: string;
cc?: string[];
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
timestamp?: string;
locationString?: string;
accessType: "global" | "allow" | "deny";
}) => {
const emailTemplate = BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType,
timestamp,
locationString,
accessType,
});
try {
await sendEmail({
to,
cc,
subject: `Blocked access attempt to ${resourceType}: ${resourceName}`,
react: emailTemplate,
system: true,
test: process.env.NODE_ENV === "development",
});
return { success: true };
} catch (error) {
return { success: false, error };
}
};

View File

@@ -1,41 +0,0 @@
import BlockedEmailAttempt from "@/components/emails/blocked-email-attempt";
import { sendEmail } from "@/lib/resend";
export const sendBlockedEmailAttemptNotification = async ({
to,
cc,
blockedEmail,
linkName,
resourceName,
resourceType = "document",
locationString,
}: {
to: string;
cc?: string[];
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
locationString?: string;
}) => {
const emailTemplate = BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType,
locationString,
});
try {
await sendEmail({
to,
cc,
subject: `Blocked email attempted to access your ${resourceType}: ${resourceName}`,
react: emailTemplate,
system: true,
test: process.env.NODE_ENV === "development",
});
return { success: true };
} catch (error) {
return { success: false, error };
}
};