mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
chore: merged main
This commit is contained in:
@@ -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 ?? '',
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user