diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index 4ddb213b8..e60531adf 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -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 }), diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index b32fb6dc1..9b4ab968e 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -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({ diff --git a/packages/lib/server-only/email/assert-org-email-send-allowed.ts b/packages/lib/server-only/email/assert-org-email-send-allowed.ts new file mode 100644 index 000000000..0260ea680 --- /dev/null +++ b/packages/lib/server-only/email/assert-org-email-send-allowed.ts @@ -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 => { + // 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 }; +}; diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index b86746226..4e1ae3dba 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -66,6 +66,7 @@ export type EmailContextResponse = { branding: BrandingSettings; settings: Omit; 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, }; }; diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index a067546a4..d2cb2239e 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -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', +});