fix: add email reporting (#2918)

This commit is contained in:
David Nguyen
2026-06-03 16:05:39 +10:00
committed by GitHub
parent 743d31651f
commit 993a494784
24 changed files with 521 additions and 33 deletions
@@ -1,13 +1,19 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { Progress } from '@documenso/ui/primitives/progress'; import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type OrganisationUsagePanelProps = { type OrganisationUsagePanelProps = {
organisationId: string; organisationId: string;
monthlyStats: Pick<OrganisationMonthlyStat, 'period' | 'documentCount' | 'emailCount' | 'apiCount'>[]; monthlyStats: Pick<
OrganisationMonthlyStat,
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
organisationClaim: OrganisationClaim; organisationClaim: OrganisationClaim;
}; };
@@ -16,34 +22,56 @@ export const OrganisationUsagePanel = ({
monthlyStats, monthlyStats,
organisationClaim, organisationClaim,
}: OrganisationUsagePanelProps) => { }: OrganisationUsagePanelProps) => {
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
// Resetting a counter only affects the current month (the server hardcodes the
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [ const rows = [
{ {
counter: 'document' as const, counter: 'document' as const,
label: <Trans>Documents</Trans>, label: <Trans>Documents</Trans>,
used: monthlyStats[0]?.documentCount ?? 0, used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota, effectiveLimit: organisationClaim.documentQuota,
}, },
{ {
counter: 'email' as const, counter: 'email' as const,
label: <Trans>Emails</Trans>, label: <Trans>Emails</Trans>,
used: monthlyStats[0]?.emailCount ?? 0, used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota, effectiveLimit: organisationClaim.emailQuota,
}, },
{ {
counter: 'api' as const, counter: 'api' as const,
label: <Trans>API requests</Trans>, label: <Trans>API requests</Trans>,
used: monthlyStats[0]?.apiCount ?? 0, used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota, effectiveLimit: organisationClaim.apiQuota,
}, },
]; ];
// Todo: This may not show if the organisation has no usage data for the current month.
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-4 rounded-md border p-4">
<div> <div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm"> <h3 className="font-medium text-sm">
<Trans>Usage for period: {monthlyStats[0]?.period || 'N/A'}</Trans> <Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3> </h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
{rows.map((row) => { {rows.map((row) => {
@@ -67,7 +95,7 @@ export const OrganisationUsagePanel = ({
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null} {row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{monthlyStats[0] && ( {selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1"> <div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} /> <OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div> </div>
@@ -75,6 +103,15 @@ export const OrganisationUsagePanel = ({
</div> </div>
); );
})} })}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div>
</div>
</div> </div>
); );
}; };
@@ -12,11 +12,17 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount'; type OrderByColumn = 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
type OrderByDirection = 'asc' | 'desc'; type OrderByDirection = 'asc' | 'desc';
const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => { const parseOrderByColumn = (value: string | undefined): OrderByColumn | undefined => {
if (value === 'documentCount' || value === 'emailCount' || value === 'apiCount' || value === 'totalCount') { if (
value === 'documentCount' ||
value === 'emailCount' ||
value === 'apiCount' ||
value === 'emailReports' ||
value === 'totalCount'
) {
return value; return value;
} }
@@ -27,10 +33,38 @@ const parseOrderByDirection = (value: string | undefined): OrderByDirection => {
return value === 'asc' ? 'asc' : 'desc'; return value === 'asc' ? 'asc' : 'desc';
}; };
export const AdminOrganisationStatsTable = () => { /**
* Number of days to divide the period's usage by to get a per-day average.
*
* For the in-progress (current) month we divide by today's UTC day-of-month so the
* average reflects elapsed days only. For a fully-elapsed past month we divide by the
* total number of days in that month.
*/
const getPeriodDivisor = (period: string): number => {
if (period === currentMonthlyPeriod()) {
return new Date().getUTCDate();
}
const [yearStr, monthStr] = period.split('-');
const year = Number(yearStr);
const month = Number(monthStr);
if (Number.isNaN(year) || Number.isNaN(month)) {
return new Date().getUTCDate();
}
// Day 0 of the following month resolves to the last day of `month`.
return new Date(Date.UTC(year, month, 0)).getUTCDate();
};
type AdminOrganisationStatsTableProps = {
showDailyAverages?: boolean;
};
export const AdminOrganisationStatsTable = ({ showDailyAverages = true }: AdminOrganisationStatsTableProps) => {
const { t } = useLingui(); const { t } = useLingui();
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -61,10 +95,17 @@ export const AdminOrganisationStatsTable = () => {
const handleColumnSort = (column: OrderByColumn) => { const handleColumnSort = (column: OrderByColumn) => {
const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc'; const nextDirection = orderByColumn === column && orderByDirection === 'desc' ? 'asc' : 'desc';
updateSearchParams({ // Use the functional updater so we merge onto the latest params. Reading the
orderByColumn: column, // captured `searchParams` here would drop filters (e.g. claimId) that changed
orderByDirection: nextDirection, // after this handler was memoised into the column definitions.
page: 1, setSearchParams((previous) => {
const next = new URLSearchParams(previous);
next.set('orderByColumn', column);
next.set('orderByDirection', nextDirection);
next.set('page', '1');
return next;
}); });
}; };
@@ -76,6 +117,27 @@ export const AdminOrganisationStatsTable = () => {
}; };
const columns = useMemo(() => { const columns = useMemo(() => {
const divisor = getPeriodDivisor(period);
const formatPerDay = (used: number) => {
const perDay = divisor > 0 ? used / divisor : 0;
const rounded = Math.round(perDay * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
};
const renderUsageCell = (used: number, quota: number | null) => {
if (showDailyAverages) {
return <span>~{formatPerDay(used)}/day</span>;
}
return (
<span>
{used} / {quota === null ? '∞' : quota}
</span>
);
};
const sortableHeader = (label: string, column: OrderByColumn) => ( const sortableHeader = (label: string, column: OrderByColumn) => (
<button <button
type="button" type="button"
@@ -100,7 +162,7 @@ export const AdminOrganisationStatsTable = () => {
header: t`Organisation`, header: t`Organisation`,
accessorKey: 'organisationName', accessorKey: 'organisationName',
cell: ({ row }) => ( cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisationId}`} className="hover:underline"> <Link to={`/admin/organisations/${row.original.organisationId}`} className="text-xs hover:underline">
{row.original.organisationName} {row.original.organisationName}
</Link> </Link>
), ),
@@ -108,27 +170,32 @@ export const AdminOrganisationStatsTable = () => {
{ {
header: t`Claim`, header: t`Claim`,
accessorKey: 'originalClaimId', accessorKey: 'originalClaimId',
cell: ({ row }) => <span className="text-muted-foreground">{row.original.originalClaimId ?? '—'}</span>, cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.originalClaimId ?? '—'}</span>,
}, },
{ {
header: t`Period`, header: t`Period`,
accessorKey: 'period', accessorKey: 'period',
cell: ({ row }) => row.original.period, cell: ({ row }) => <span className="text-xs">{row.original.period}</span>,
}, },
{ {
header: () => sortableHeader(t`Documents`, 'documentCount'), header: () => sortableHeader(t`Documents`, 'documentCount'),
accessorKey: 'documentCount', accessorKey: 'documentCount',
cell: ({ row }) => row.original.documentCount, cell: ({ row }) => renderUsageCell(row.original.documentCount, row.original.documentQuota),
}, },
{ {
header: () => sortableHeader(t`Emails`, 'emailCount'), header: () => sortableHeader(t`Emails`, 'emailCount'),
accessorKey: 'emailCount', accessorKey: 'emailCount',
cell: ({ row }) => row.original.emailCount, cell: ({ row }) => renderUsageCell(row.original.emailCount, row.original.emailQuota),
}, },
{ {
header: () => sortableHeader(t`API`, 'apiCount'), header: () => sortableHeader(t`API`, 'apiCount'),
accessorKey: 'apiCount', accessorKey: 'apiCount',
cell: ({ row }) => row.original.apiCount, cell: ({ row }) => renderUsageCell(row.original.apiCount, row.original.apiQuota),
},
{
header: () => sortableHeader(t`Reports`, 'emailReports'),
accessorKey: 'emailReports',
cell: ({ row }) => row.original.emailReports,
}, },
{ {
header: () => sortableHeader(t`Total`, 'totalCount'), header: () => sortableHeader(t`Total`, 'totalCount'),
@@ -136,14 +203,19 @@ export const AdminOrganisationStatsTable = () => {
cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>, cell: ({ row }) => <span className="font-medium">{row.original.totalCount}</span>,
}, },
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
// `searchParams` must be a dependency: `handleColumnSort` closes over `setSearchParams`,
// whose functional updater is bound to the `searchParams` captured at creation time.
// Without this, changing a filter (e.g. claimId) wouldn't refresh the memoised handler,
// and sorting would merge onto stale params and drop the active filter.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, orderByColumn, orderByDirection]); }, [t, orderByColumn, orderByDirection, period, showDailyAverages, searchParams]);
return ( return (
<div> <div>
<DataTable <DataTable
columns={columns} columns={columns}
data={results.data} data={results.data}
rowClassName="text-xs"
perPage={results.perPage} perPage={results.perPage}
currentPage={results.currentPage} currentPage={results.currentPage}
totalPages={results.totalPages} totalPages={results.totalPages}
@@ -177,6 +249,9 @@ export const AdminOrganisationStatsTable = () => {
<TableCell> <TableCell>
<Skeleton className="h-4 w-10 rounded-full" /> <Skeleton className="h-4 w-10 rounded-full" />
</TableCell> </TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</> </>
), ),
}} }}
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
<div> <div>
<Input <Input
type="search" type="search"
placeholder={_(msg`Search by document title`)} placeholder={_(msg`Search by document title, team:123 or user:123`)}
value={term} value={term}
onChange={(e) => setTerm(e.target.value)} onChange={(e) => setTerm(e.target.value)}
/> />
@@ -3,6 +3,7 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -52,6 +53,8 @@ export default function OrganisationStats() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [displayMode, setDisplayMode] = useState<'quotas' | 'averages'>('quotas');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []); const periodOptions = useMemo(() => generatePeriodOptions(), []);
@@ -157,9 +160,29 @@ export default function OrganisationStats() {
</div> </div>
<div className="mt-4"> <div className="mt-4">
<AdminOrganisationStatsTable /> <AdminOrganisationStatsTable showDailyAverages={displayMode === 'averages'} />
</div> </div>
<RadioGroup
value={displayMode}
onValueChange={(value) => setDisplayMode(value === 'averages' ? 'averages' : 'quotas')}
className="mt-4 flex flex-col gap-3 rounded-lg border border-border p-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-quotas" value="quotas" />
<label htmlFor="display-quotas" className="text-muted-foreground text-sm">
<Trans>Show quotas for documents, emails and API usages</Trans>
</label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="display-averages" value="averages" />
<label htmlFor="display-averages" className="text-muted-foreground text-sm">
<Trans>Show daily averages for documents, emails and API usages</Trans>
</label>
</div>
</RadioGroup>
<Alert variant="neutral" className="mt-4"> <Alert variant="neutral" className="mt-4">
<AlertDescription> <AlertDescription>
<Trans> <Trans>
@@ -0,0 +1,92 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import type { Route } from './+types/report.$token';
export async function loader({ params }: Route.LoaderArgs) {
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
// Only validate the token on GET. The report itself is performed by an explicit
// mutation (triggered by the recipient clicking the button), so an automated email
// link scanner / prefetcher cannot register a report simply by fetching the URL.
const recipient = await prisma.recipient.findFirst({
where: { token },
select: { id: true },
});
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
return {
token,
};
}
export default function ReportSenderPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { t } = useLingui();
const { toast } = useToast();
const [isReported, setIsReported] = useState(false);
const { mutate: reportSender, isPending } = trpc.envelope.recipient.report.useMutation({
onSuccess: () => setIsReported(true),
onError: () => {
toast({
title: t`Something went wrong`,
description: t`We were unable to report this sender at this time. Please try again later.`,
variant: 'destructive',
});
},
});
if (isReported) {
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Sender reported</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel
free to reach out to our{' '}
<a className="text-documenso-700 underline" href={`mailto:${SUPPORT_EMAIL}`}>
support team
</a>
.
</Trans>
</p>
</div>
);
}
return (
<div className="-mx-4 flex flex-col items-center px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28">
<h1 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl">
<Trans>Report this sender?</Trans>
</h1>
<p className="mt-4 max-w-[60ch] text-center text-muted-foreground leading-normal">
<Trans>
If you did not expect this email or believe it is spam, you can report the sender to our team for review.
</Trans>
</p>
<Button className="mt-6" loading={isPending} onClick={() => reportSender({ token })}>
<Trans>Report sender</Trans>
</Button>
</div>
);
}
@@ -0,0 +1,65 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const getEmailReports = async (organisationId: string) => {
const stat = await prisma.organisationMonthlyStat.findUnique({
where: {
organisationId_period: {
organisationId,
period: currentMonthlyPeriod(),
},
},
select: { emailReports: true },
});
return stat?.emailReports ?? 0;
};
test('[REPORT_SENDER]: only reports the sender after the button is clicked', async ({ page }) => {
const { user, team, organisation } = await seedUser();
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
const token = document.recipients[0].token;
expect(await getEmailReports(organisation.id)).toBe(0);
await page.goto(`/report/${token}`);
// Visiting the page (GET) must not register a report.
await expect(page.getByRole('heading', { name: 'Report this sender?' })).toBeVisible();
expect(await getEmailReports(organisation.id)).toBe(0);
await page.getByRole('button', { name: 'Report sender' }).click();
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
expect(await getEmailReports(organisation.id)).toBe(1);
});
test('[REPORT_SENDER]: does not double count within the rate limit window', async ({ page }) => {
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Rate limits are bypassed');
const { user, team, organisation } = await seedUser();
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
const token = document.recipients[0].token;
await page.goto(`/report/${token}`);
await page.getByRole('button', { name: 'Report sender' }).click();
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
await page.goto(`/report/${token}`);
await page.getByRole('button', { name: 'Report sender' }).click();
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
expect(await getEmailReports(organisation.id)).toBe(1);
});
test('[REPORT_SENDER]: returns 404 for an invalid token', async ({ page }) => {
const response = await page.goto('/report/not-a-real-token');
expect(response?.status()).toBe(404);
});
@@ -5,13 +5,26 @@ import { useBranding } from '../providers/branding';
export type TemplateFooterProps = { export type TemplateFooterProps = {
isDocument?: boolean; isDocument?: boolean;
reportUrl?: string;
}; };
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => { export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterProps) => {
const branding = useBranding(); const branding = useBranding();
return ( return (
<Section> <Section>
{reportUrl && (
<Text className="my-4 text-base text-slate-400">
<Trans>
Did not expect this email?{' '}
<Link className="text-[#7AC455]" href={reportUrl}>
Click here to report the sender
</Link>
. Never sign a document you don't recognize or weren't expecting.
</Trans>
</Text>
)}
{isDocument && !branding.brandingHidePoweredBy && ( {isDocument && !branding.brandingHidePoweredBy && (
<Text className="my-4 text-base text-slate-400"> <Text className="my-4 text-base text-slate-400">
<Trans> <Trans>
@@ -9,6 +9,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & { export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
customBody?: string; customBody?: string;
reportUrl?: string;
}; };
export const DocumentCompletedEmailTemplate = ({ export const DocumentCompletedEmailTemplate = ({
@@ -16,6 +17,7 @@ export const DocumentCompletedEmailTemplate = ({
documentName = 'Open Source Pledge.pdf', documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
reportUrl,
}: DocumentCompletedEmailTemplateProps) => { }: DocumentCompletedEmailTemplateProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const branding = useBranding(); const branding = useBranding();
@@ -51,7 +53,7 @@ export const DocumentCompletedEmailTemplate = ({
</Container> </Container>
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<TemplateFooter /> <TemplateFooter reportUrl={reportUrl} />
</Container> </Container>
</Section> </Section>
</Body> </Body>
+3 -1
View File
@@ -20,6 +20,7 @@ export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInvitePro
teamEmail?: string; teamEmail?: string;
includeSenderDetails?: boolean; includeSenderDetails?: boolean;
organisationType?: OrganisationType; organisationType?: OrganisationType;
reportUrl?: string;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@@ -34,6 +35,7 @@ export const DocumentInviteEmailTemplate = ({
teamName = '', teamName = '',
includeSenderDetails, includeSenderDetails,
organisationType, organisationType,
reportUrl,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const branding = useBranding(); const branding = useBranding();
@@ -114,7 +116,7 @@ export const DocumentInviteEmailTemplate = ({
<Hr className="mx-auto mt-12 max-w-xl" /> <Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<TemplateFooter /> <TemplateFooter reportUrl={reportUrl} />
</Container> </Container>
</Section> </Section>
</Body> </Body>
@@ -16,6 +16,7 @@ export type DocumentReminderEmailTemplateProps = {
assetBaseUrl?: string; assetBaseUrl?: string;
customBody?: string; customBody?: string;
role: RecipientRole; role: RecipientRole;
reportUrl?: string;
}; };
export const DocumentReminderEmailTemplate = ({ export const DocumentReminderEmailTemplate = ({
@@ -25,6 +26,7 @@ export const DocumentReminderEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role = RecipientRole.SIGNER, role = RecipientRole.SIGNER,
reportUrl,
}: DocumentReminderEmailTemplateProps) => { }: DocumentReminderEmailTemplateProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const branding = useBranding(); const branding = useBranding();
@@ -75,7 +77,7 @@ export const DocumentReminderEmailTemplate = ({
<Hr className="mx-auto mt-12 max-w-xl" /> <Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<TemplateFooter /> <TemplateFooter reportUrl={reportUrl} />
</Container> </Container>
</Section> </Section>
</Body> </Body>
@@ -211,6 +211,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
}; };
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`; const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
const reportUrl =
recipient.role === RecipientRole.CC ? `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}` : undefined;
const template = createElement(DocumentCompletedEmailTemplate, { const template = createElement(DocumentCompletedEmailTemplate, {
documentName: envelope.title, documentName: envelope.title,
@@ -220,6 +222,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
isDirectTemplate && envelope.documentMeta?.message isDirectTemplate && envelope.documentMeta?.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate) ? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined, : undefined,
reportUrl,
}); });
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([
@@ -165,6 +165,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, { const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title, documentName: envelope.title,
@@ -180,6 +181,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
teamName: team?.name, teamName: team?.name,
teamEmail: team?.teamEmail?.email, teamEmail: team?.teamEmail?.email,
includeSenderDetails: settings.includeSenderDetails, includeSenderDetails: settings.includeSenderDetails,
reportUrl,
}); });
if (isRecipientEmailValidForSending(recipient)) { if (isRecipientEmailValidForSending(recipient)) {
@@ -154,6 +154,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
// Meter reminder emails against the organisation email quota/stats. Reminders // Meter reminder emails against the organisation email quota/stats. Reminders
// are unsolicited (the recipient didn't opt in to them) and can recur, so they // are unsolicited (the recipient didn't opt in to them) and can recur, so they
@@ -188,6 +189,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
signDocumentLink, signDocumentLink,
customBody: emailMessage, customBody: emailMessage,
role: recipient.role, role: recipient.role,
reportUrl,
}); });
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([
@@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Prisma } from '@prisma/client'; import { EnvelopeType, type Prisma } from '@prisma/client';
import { z } from 'zod';
import type { FindResultResponse } from '../../types/search-params'; import type { FindResultResponse } from '../../types/search-params';
@@ -9,6 +10,16 @@ export interface AdminFindDocumentsOptions {
perPage?: number; perPage?: number;
} }
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
const emptyResponse = {
data: [],
count: 0,
currentPage: 1,
perPage: 10,
totalPages: 0,
};
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => { export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
? undefined ? undefined
@@ -19,7 +30,35 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
}, },
}; };
if (query && query.startsWith('envelope_')) { if (query?.startsWith('user:')) {
const parsedUserId = ZPositiveIntegerSchema.safeParse(query.slice('user:'.length));
if (parsedUserId.success) {
termFilters = {
userId: {
equals: parsedUserId.data,
},
};
} else {
return emptyResponse;
}
}
if (query?.startsWith('team:')) {
const parsedTeamId = ZPositiveIntegerSchema.safeParse(query.slice('team:'.length));
if (parsedTeamId.success) {
termFilters = {
teamId: {
equals: parsedTeamId.data,
},
};
} else {
return emptyResponse;
}
}
if (query && query?.startsWith('envelope_')) {
termFilters = { termFilters = {
id: { id: {
equals: query, equals: query,
@@ -27,7 +66,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
}; };
} }
if (query && query.startsWith('document_')) { if (query && query?.startsWith('document_')) {
termFilters = { termFilters = {
secondaryId: { secondaryId: {
equals: query, equals: query,
@@ -210,6 +210,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, { const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title, documentName: envelope.title,
@@ -225,6 +226,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
selfSigner, selfSigner,
organisationType, organisationType,
teamName: envelope.team?.name, teamName: envelope.team?.name,
reportUrl,
}); });
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([
@@ -66,6 +66,12 @@ export const linkOrgAccountRateLimit = createRateLimit({
window: '1h', window: '1h',
}); });
export const reportSenderRateLimit = createRateLimit({
action: 'recipient.report-sender',
max: 1,
window: '7d',
});
// ---- API (Tier 4 - Standard) ---- // ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({ export const apiV1RateLimit = createRateLimit({
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrganisationMonthlyStat" ADD COLUMN "emailReports" INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -322,6 +322,7 @@ model OrganisationMonthlyStat {
documentCount Int @default(0) documentCount Int @default(0)
emailCount Int @default(0) emailCount Int @default(0)
apiCount Int @default(0) apiCount Int @default(0)
emailReports Int @default(0)
@@unique([organisationId, period]) @@unique([organisationId, period])
@@index([organisationId]) @@index([organisationId])
@@ -32,7 +32,7 @@ type FindOrganisationStatsOptions = {
claimId?: string; claimId?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'totalCount'; orderByColumn?: 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' | 'totalCount';
orderByDirection?: 'asc' | 'desc'; orderByDirection?: 'asc' | 'desc';
}; };
@@ -91,6 +91,10 @@ export const findOrganisationStats = async ({
'OrganisationMonthlyStat.documentCount as documentCount', 'OrganisationMonthlyStat.documentCount as documentCount',
'OrganisationMonthlyStat.emailCount as emailCount', 'OrganisationMonthlyStat.emailCount as emailCount',
'OrganisationMonthlyStat.apiCount as apiCount', 'OrganisationMonthlyStat.apiCount as apiCount',
'OrganisationMonthlyStat.emailReports as emailReports',
'OrganisationClaim.documentQuota as documentQuota',
'OrganisationClaim.emailQuota as emailQuota',
'OrganisationClaim.apiQuota as apiQuota',
totalCountExpression.as('totalCount'), totalCountExpression.as('totalCount'),
eb.fn.countAll().over().as('totalRows'), eb.fn.countAll().over().as('totalRows'),
]) ])
@@ -99,6 +103,7 @@ export const findOrganisationStats = async ({
.with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection)) .with('documentCount', () => qb.orderBy('OrganisationMonthlyStat.documentCount', orderByDirection))
.with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection)) .with('emailCount', () => qb.orderBy('OrganisationMonthlyStat.emailCount', orderByDirection))
.with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection)) .with('apiCount', () => qb.orderBy('OrganisationMonthlyStat.apiCount', orderByDirection))
.with('emailReports', () => qb.orderBy('OrganisationMonthlyStat.emailReports', orderByDirection))
.with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection)) .with('totalCount', () => qb.orderBy(totalCountExpression, orderByDirection))
.with(undefined, () => .with(undefined, () =>
// Default ordering mirrors the desired SQL: email, api, document descending. // Default ordering mirrors the desired SQL: email, api, document descending.
@@ -126,6 +131,10 @@ export const findOrganisationStats = async ({
documentCount: Number(row.documentCount), documentCount: Number(row.documentCount),
emailCount: Number(row.emailCount), emailCount: Number(row.emailCount),
apiCount: Number(row.apiCount), apiCount: Number(row.apiCount),
emailReports: Number(row.emailReports),
documentQuota: row.documentQuota === null ? null : Number(row.documentQuota),
emailQuota: row.emailQuota === null ? null : Number(row.emailQuota),
apiQuota: row.apiQuota === null ? null : Number(row.apiQuota),
totalCount: Number(row.totalCount), totalCount: Number(row.totalCount),
})); }));
@@ -9,7 +9,7 @@ export const ZFindOrganisationStatsRequestSchema = ZFindSearchParamsSchema.exten
.optional(), .optional(),
claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(), claimId: z.string().describe('Filter stats by the original subscription claim ID.').optional(),
orderByColumn: z orderByColumn: z
.enum(['documentCount', 'emailCount', 'apiCount', 'totalCount']) .enum(['documentCount', 'emailCount', 'apiCount', 'emailReports', 'totalCount'])
.describe('The column to sort by.') .describe('The column to sort by.')
.optional(), .optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'), orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
@@ -26,6 +26,10 @@ export const ZFindOrganisationStatsResponseSchema = ZFindResultResponse.extend({
documentCount: z.number(), documentCount: z.number(),
emailCount: z.number(), emailCount: z.number(),
apiCount: z.number(), apiCount: z.number(),
emailReports: z.number(),
documentQuota: z.number().nullable(),
emailQuota: z.number().nullable(),
apiQuota: z.number().nullable(),
totalCount: z.number(), totalCount: z.number(),
}) })
.array(), .array(),
@@ -53,6 +53,7 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
documentCount: true, documentCount: true,
emailCount: true, emailCount: true,
apiCount: true, apiCount: true,
emailReports: true,
}), }),
), ),
}); });
@@ -0,0 +1,95 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { reportSenderRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import { ZReportRecipientRequestSchema, ZReportRecipientResponseSchema } from './report-recipient.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
* Recipients report a sender directly from a link in their email, so no session or
* API token is required.
*/
export const reportRecipientRoute = procedure
.input(ZReportRecipientRequestSchema)
.output(ZReportRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token } = input;
if (!token) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Token is required',
});
}
const { ipAddress } = ctx.metadata.requestMetadata;
const recipient = await prisma.recipient.findFirst({
where: { token },
select: {
id: true,
envelopeId: true,
envelope: {
select: {
team: {
select: {
id: true,
organisationId: true,
},
},
},
},
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient could not be found',
});
}
// Rate limit to ensure we aren't double reporting by accident.
const rateLimitResult = await reportSenderRateLimit.check({
ip: ipAddress ?? 'unknown',
identifier: `${recipient.envelopeId}:${recipient.id}`,
});
if (rateLimitResult.isLimited) {
return;
}
const period = currentMonthlyPeriod();
const { organisationId } = recipient.envelope.team;
// Incrementing the stat is a non-critical side effect; fail soft so a transient
// DB error never turns reporting into a user-facing error.
await prisma.organisationMonthlyStat
.upsert({
where: {
organisationId_period: {
organisationId,
period,
},
},
update: {
emailReports: { increment: 1 },
},
create: {
id: generateDatabaseId('org_monthly_stat'),
organisationId,
period,
emailReports: 1,
},
})
.catch((error) => {
ctx.logger.error({
msg: 'Failed to increment organisation emailReports stat',
error,
});
});
ctx.logger.info({
msg: `Email reported. Recipient: ${recipient.id}. Envelope: ${recipient.envelopeId}.`,
});
});
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZReportRecipientRequestSchema = z.object({
token: z.string().min(1).describe('The recipient token from the email link used to report the sender.'),
});
export const ZReportRecipientResponseSchema = z.void();
export type TReportRecipientRequest = z.infer<typeof ZReportRecipientRequestSchema>;
@@ -20,6 +20,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients'; import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient'; import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient'; import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients'; import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs'; import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
import { findEnvelopesRoute } from './find-envelopes'; import { findEnvelopesRoute } from './find-envelopes';
@@ -66,6 +67,7 @@ export const envelopeRouter = router({
updateMany: updateEnvelopeRecipientsRoute, updateMany: updateEnvelopeRecipientsRoute,
delete: deleteEnvelopeRecipientRoute, delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute, set: setEnvelopeRecipientsRoute,
report: reportRecipientRoute,
}, },
field: { field: {
get: getEnvelopeFieldRoute, get: getEnvelopeFieldRoute,