chore: merged main

This commit is contained in:
Catalin Pit
2026-06-09 08:32:31 +03:00
130 changed files with 7382 additions and 1508 deletions
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -79,7 +78,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -108,7 +107,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -45,7 +44,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -89,7 +88,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -32,9 +32,12 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
throw new Error('User not found');
}
const token = user.passwordResetTokens[0].token;
const token = user.passwordResetTokens[0]?.token;
if (!token) {
throw new Error('Password reset token not found for user');
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -126,7 +125,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -187,7 +186,9 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
if (!isEnvelopeDeleteEmailEnabled) {
// Skip sending if the email is disabled for this document or the organisation
// has email sending disabled entirely.
if (!isEnvelopeDeleteEmailEnabled || emailsDisabled) {
return deletedEnvelope;
}
@@ -222,7 +223,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,4 +1,3 @@
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';
@@ -151,15 +150,29 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId, claims } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const {
branding,
emailLanguage,
organisationType,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't resend any emails if the organisation has email sending disabled.
if (user.disabled || emailsDisabled) {
return envelope;
}
// Assert that there is enough quota to send the emails.
await assertOrganisationRatesAndLimits({
@@ -244,7 +257,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -46,7 +45,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -76,7 +75,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name: name || '',
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -47,7 +46,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -56,6 +55,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
meta: envelope.documentMeta,
});
// Don't send any emails if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
if (!isDocumentPendingEmailEnabled) {
@@ -89,7 +93,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -0,0 +1,107 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { z } from 'zod';
/**
* Config keys that hold secret values across all transport types.
*
* Secrets are never sent back to the client, so on update an empty incoming
* value means "keep the existing secret". This list lets the update route know
* which fields to preserve when left blank.
*
* Keep in sync with the fields marked `Secret` in the schemas below.
*/
export const EMAIL_TRANSPORT_SECRET_KEYS = ['password', 'apiKey'] as const;
export const ZSmtpAuthConfigSchema = z.object({
type: z.literal('SMTP_AUTH'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
ignoreTLS: z.boolean().default(false),
username: z.string().optional(),
password: z.string().optional(), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
service: z.string().optional(),
});
export const ZSmtpApiConfigSchema = z.object({
type: z.literal('SMTP_API'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
apiKeyUser: z.string().optional(),
});
export const ZResendConfigSchema = z.object({
type: z.literal('RESEND'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
});
export const ZMailChannelsConfigSchema = z.object({
type: z.literal('MAILCHANNELS'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
endpoint: z.string().optional(),
});
export const ZEmailTransportConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema,
ZSmtpApiConfigSchema,
ZResendConfigSchema,
ZMailChannelsConfigSchema,
]);
export type TEmailTransportConfig = z.infer<typeof ZEmailTransportConfigSchema>;
/**
* Non-secret view of a transport config (secret fields removed).
*
* Safe to return to the client so the edit form can pre-fill the connection
* settings without exposing secrets.
*/
export const ZEmailTransportPublicConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema.omit({ password: true }),
ZSmtpApiConfigSchema.omit({ apiKey: true }),
ZResendConfigSchema.omit({ apiKey: true }),
ZMailChannelsConfigSchema.omit({ apiKey: true }),
]);
export type TEmailTransportPublicConfig = z.infer<typeof ZEmailTransportPublicConfigSchema>;
/**
* Strips secret fields (see EMAIL_TRANSPORT_SECRET_KEYS) from a transport
* config, returning only the non-secret connection settings.
*/
export const toPublicEmailTransportConfig = (config: TEmailTransportConfig): TEmailTransportPublicConfig => {
const publicConfig: Record<string, unknown> = { ...config };
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
delete publicConfig[key];
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return publicConfig as TEmailTransportPublicConfig;
};
export const encryptEmailTransportConfig = (config: TEmailTransportConfig): string => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(config),
});
};
export const decryptEmailTransportConfig = (encrypted: string): TEmailTransportConfig => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decrypted = Buffer.from(
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, data: encrypted }),
).toString('utf-8');
return ZEmailTransportConfigSchema.parse(JSON.parse(decrypted));
};
@@ -1,3 +1,4 @@
import { mailer } from '@documenso/email/mailer';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@@ -8,15 +9,18 @@ import type {
OrganisationType,
} from '@documenso/prisma/client';
import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { extractDerivedTeamSettings } from '../../utils/teams';
import { resolveEmailTransport } from './resolve-email-transport';
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
@@ -66,21 +70,27 @@ export type EmailContextResponse = {
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
/**
* Whether the organisation is prevented from sending emails.
*
* When true, ALL emails sent on behalf of this organisation must be skipped.
*/
emailsDisabled: boolean;
organisationId: string;
organisationType: OrganisationType;
emailTransport: Transporter;
senderEmail: {
name: string;
address: string;
};
replyToEmail: string | undefined;
emailLanguage: string;
isOrganisationOwnerDisabled: boolean;
};
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage' | 'emailTransport'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
@@ -90,13 +100,45 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
const transportResolution = emailContext.claims.emailTransportId
? await resolveEmailTransport(emailContext.claims.emailTransportId)
: null;
// A configured transport that fails to resolve is an operational problem, not
// "no transport". Surface it (alertable) before silently falling back to the
// system mailer + Documenso sender, so the degraded organisation is findable.
if (emailContext.claims.emailTransportId && !transportResolution) {
// Todo: Logging
logger.error({
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
emailTransportId: emailContext.claims.emailTransportId,
organisationId: emailContext.organisationId,
});
}
const resolvedTransportData = transportResolution
? {
name: transportResolution.row.fromName,
address: transportResolution.row.fromAddress,
transport: transportResolution.transporter,
}
: {
name: DOCUMENSO_INTERNAL_EMAIL.name,
address: DOCUMENSO_INTERNAL_EMAIL.address,
transport: mailer,
};
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
emailLanguage,
};
}
@@ -115,16 +157,29 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
// Custom-domain sender (emailDomains): always use the env mailer (SES) and the
// custom sender; the per-plan transport is ignored entirely here.
if (foundSenderEmail) {
return {
...emailContext,
emailTransport: mailer,
senderEmail: {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
},
replyToEmail,
emailLanguage,
};
}
// No custom-domain sender → per-plan transport (if any) supplies transport + from-address.
return {
...emailContext,
senderEmail,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail,
emailLanguage,
};
@@ -171,9 +226,9 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
),
settings: organisation.organisationGlobalSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
organisationId: organisation.id,
organisationType: organisation.type,
isOrganisationOwnerDisabled: organisation.owner.disabled,
};
};
@@ -223,9 +278,9 @@ const handleTeamEmailContext = async (teamId: number) => {
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
settings: teamSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
organisationId: organisation.id,
organisationType: organisation.type,
isOrganisationOwnerDisabled: organisation.owner.disabled,
};
};
@@ -0,0 +1,42 @@
import { buildTransport } from '@documenso/email/transports/build-transport';
import { prisma } from '@documenso/prisma';
import type { EmailTransport } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { logger } from '../../utils/logger';
import { decryptEmailTransportConfig } from './email-transport-config';
export type ResolvedEmailTransport = {
row: EmailTransport;
transporter: Transporter;
};
/**
* Loads an EmailTransport row, decrypts its config and builds a nodemailer
* Transporter. Returns null when the id does not resolve or the stored config
* cannot be decrypted/built (caller should fall back to the env mailer).
*/
export const resolveEmailTransport = async (emailTransportId: string): Promise<ResolvedEmailTransport | null> => {
const row = await prisma.emailTransport.findUnique({
where: { id: emailTransportId },
});
if (!row) {
return null;
}
try {
const config = decryptEmailTransportConfig(row.config);
const transporter = buildTransport(config);
return { row, transporter };
} catch (err) {
// Todo: Logging
logger.error({
msg: 'Failed to decrypt or build the configured email transport',
err,
emailTransportId,
});
return null;
}
};
@@ -2,7 +2,6 @@ import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -187,7 +186,7 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -195,6 +194,12 @@ export const sendOrganisationMemberInviteEmail = async ({
},
});
// Member invites can be sent to anyone, so block them when the organisation has email
// sending disabled.
if (emailsDisabled) {
return;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
@@ -209,7 +214,7 @@ export const sendOrganisationMemberInviteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
@@ -6,7 +6,6 @@ import { OrganisationMemberRole, OrganisationType, Prisma, type SubscriptionClai
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { InternalClaim } from '../../types/subscription';
import { INTERNAL_CLAIM_ID } from '../../types/subscription';
import { generateDatabaseId, prefixedId } from '../../universal/id';
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
@@ -18,7 +17,7 @@ type CreateOrganisationOptions = {
type: OrganisationType;
url?: string;
customerId?: string;
claim: InternalClaim;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
};
export const createOrganisation = async ({ name, url, type, userId, customerId, claim }: CreateOrganisationOptions) => {
@@ -191,21 +190,23 @@ export const createOrganisationClaimUpsertData = (
subscriptionClaim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>,
) => {
// Done like this to ensure type errors are thrown if items are added.
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
apiQuota: subscriptionClaim.apiQuota,
};
const data: Omit<Prisma.SubscriptionClaimUncheckedCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> =
{
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
};
return {
...data,
@@ -5,6 +5,7 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import type { EmailContextResponse } from '../email/get-email-context';
@@ -12,7 +13,7 @@ export type SendOrganisationDeleteEmailOptions = {
email: string;
organisationName: string;
deletedByAdmin?: boolean;
emailContext: EmailContextResponse;
emailContext: Omit<EmailContextResponse, 'emailTransport'>;
};
/**
@@ -30,7 +31,7 @@ export const sendOrganisationDeleteEmail = async ({
deletedByAdmin,
});
const { branding, emailLanguage, senderEmail } = emailContext;
const { branding, emailLanguage } = emailContext;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
@@ -39,9 +40,13 @@ export const sendOrganisationDeleteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
// This is sent through the global Documenso mailer (the org's transport is
// intentionally not used during deletion), so use the Documenso sender to keep
// the From-address aligned with the sending infrastructure (SPF/DKIM). Note the
// org's `senderEmail` on `emailContext` could be a custom transport address.
await mailer.sendMail({
to: email,
from: senderEmail,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
html,
text,
@@ -0,0 +1,47 @@
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
};
type ComputeQuotaFlagsOptions = {
quotas: {
documentQuota: number | null;
emailQuota: number | null;
apiQuota: number | null;
};
usage?: {
documentCount?: number;
emailCount?: number;
apiCount?: number;
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*
* Note: this `>=` is intentionally the "reached" signal for the banner and is
* distinct from enforcement in `check-monthly-quota.ts`, which blocks the
* action that crosses the boundary using a strict `>` on the post-increment
* count. Do not "align" them — they answer different questions.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
@@ -1,16 +1,5 @@
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
export type LimitCounter = 'api' | 'document' | 'email';
export type LimitOptions = {
organisationId: string;
organisationClaim?: OrganisationClaim;
monthlyStat?: OrganisationMonthlyStat;
// Units to reserve. Default 1. Must be >= 1.
count?: number;
};
export type RateLimitEntry = {
window: `${number}${'s' | 'm' | 'h' | 'd'}`;
max: number;
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -152,7 +151,16 @@ export const deleteEnvelopeRecipient = async ({
assetBaseUrl,
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -161,6 +169,11 @@ export const deleteEnvelopeRecipient = async ({
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return deletedRecipient;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails
// outside the email limits.
@@ -189,7 +202,7 @@ export const deleteEnvelopeRecipient = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -89,14 +88,15 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
@@ -285,6 +285,7 @@ export const setDocumentRecipients = async ({
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
emailsDisabled ||
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
@@ -328,7 +329,7 @@ export const setDocumentRecipients = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,5 +1,3 @@
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import type { SubscriptionClaim } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -12,12 +10,6 @@ export const getSubscriptionClaim = async (
});
if (!subscriptionClaim) {
// Temporary fallback for free claim so we don't break self-hosters who somehow removed it
// from the database.
if (claimId === INTERNAL_CLAIM_ID.FREE) {
return internalClaims[INTERNAL_CLAIM_ID.FREE];
}
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Subscription claim ${claimId} not found`,
});
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -117,7 +116,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -136,7 +135,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`A request to use your email has been initiated by ${team.name} on Documenso`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -24,7 +23,7 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -87,7 +86,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
+2 -3
View File
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -120,7 +119,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
teamUrl: team.url,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -135,7 +134,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),