mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: add temp email rate limit (#2879)
This commit is contained in:
@@ -16,6 +16,7 @@ import { createElement } from 'react';
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
||||
import { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
@@ -83,14 +84,15 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const customEmail = envelope?.documentMeta;
|
||||
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
@@ -162,6 +164,22 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
});
|
||||
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
||||
|
||||
if (!sendCheck.allowed) {
|
||||
// TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried.
|
||||
io.logger.warn({
|
||||
msg: 'Recipient signing email dropped: org rate limit exceeded',
|
||||
organisationId,
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
reason: sendCheck.reason,
|
||||
resetsAt: sendCheck.resetsAt,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
@@ -26,6 +27,7 @@ import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -120,14 +122,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map(async (recipient) => {
|
||||
@@ -200,6 +203,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
||||
|
||||
if (!sendCheck.allowed) {
|
||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||
message: 'Organisation email send rate limit exceeded',
|
||||
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
|
||||
});
|
||||
}
|
||||
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
recipientEmailRateLimit1d,
|
||||
recipientEmailRateLimit5m,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
|
||||
type AssertOrgEmailSendAllowedOptions = {
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date };
|
||||
|
||||
/**
|
||||
* TEMPORARY: rate-limit unsolicited recipient emails per organisation.
|
||||
*
|
||||
* Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a
|
||||
* result object so callers can choose to silently drop (job path) or throw
|
||||
* (sync path).
|
||||
*
|
||||
* Remove this helper and all callers when the comprehensive abuse-prevention
|
||||
* design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md
|
||||
*/
|
||||
export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise<Result> => {
|
||||
// Self-hosted instances are not behind the SES cap.
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const ip = `org:${options.organisationId}`;
|
||||
|
||||
const fiveMinResult = await recipientEmailRateLimit5m.check({ ip });
|
||||
if (fiveMinResult.isLimited) {
|
||||
return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset };
|
||||
}
|
||||
|
||||
const dailyResult = await recipientEmailRateLimit1d.check({ ip });
|
||||
if (dailyResult.isLimited) {
|
||||
return { allowed: false, reason: '1d', resetsAt: dailyResult.reset };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
};
|
||||
@@ -66,6 +66,7 @@ export type EmailContextResponse = {
|
||||
branding: BrandingSettings;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
claims: OrganisationClaim;
|
||||
organisationId: string;
|
||||
organisationType: OrganisationType;
|
||||
senderEmail: {
|
||||
name: string;
|
||||
@@ -164,6 +165,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
|
||||
),
|
||||
settings: organisation.organisationGlobalSettings,
|
||||
claims,
|
||||
organisationId: organisation.id,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
};
|
||||
@@ -208,6 +210,7 @@ const handleTeamEmailContext = async (teamId: number) => {
|
||||
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
|
||||
settings: teamSettings,
|
||||
claims,
|
||||
organisationId: organisation.id,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -97,3 +97,17 @@ export const fileUploadRateLimit = createRateLimit({
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ----
|
||||
|
||||
export const recipientEmailRateLimit5m = createRateLimit({
|
||||
action: 'email.send.recipient.5m',
|
||||
max: 100,
|
||||
window: '5m',
|
||||
});
|
||||
|
||||
export const recipientEmailRateLimit1d = createRateLimit({
|
||||
action: 'email.send.recipient.1d',
|
||||
max: 1500,
|
||||
window: '1d',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user