From ebf5b75a19345b16298a20d74effd42a655a66ef Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 5 Jun 2026 17:55:10 +1000 Subject: [PATCH 01/16] fix: add email disable flag (#2931) --- .../app/components/general/billing-plans.tsx | 19 +-- .../stripe/webhook/on-subscription-created.ts | 8 +- .../send-document-cancelled-emails.handler.ts | 6 +- .../send-document-completed-emails.handler.ts | 6 +- ...d-owner-recipient-expired-email.handler.ts | 7 +- .../emails/send-rejection-emails.handler.ts | 8 +- .../emails/send-signing-email.handler.ts | 6 +- .../internal/backport-subscription-claims.ts | 1 + .../process-signing-reminder.handler.ts | 9 +- .../server-only/document/delete-document.ts | 6 +- .../server-only/document/resend-document.ts | 31 +++-- .../document/send-pending-email.ts | 7 +- .../server-only/email/get-email-context.ts | 11 +- .../create-organisation-member-invites.ts | 8 +- .../organisation/create-organisation.ts | 3 +- .../recipient/delete-envelope-recipient.ts | 22 +-- .../recipient/set-document-recipients.ts | 18 +-- .../subscription/get-subscription-claim.ts | 8 -- packages/lib/types/subscription.ts | 130 ++---------------- 19 files changed, 116 insertions(+), 198 deletions(-) diff --git a/apps/remix/app/components/general/billing-plans.tsx b/apps/remix/app/components/general/billing-plans.tsx index 538471b49..1de96f1d0 100644 --- a/apps/remix/app/components/general/billing-plans.tsx +++ b/apps/remix/app/components/general/billing-plans.tsx @@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { if (plan[interval] && plan[interval].isVisibleInApp) { prices.push({ ...plan[interval], - memberCount: plan.memberCount, claim: plan.id, }); } @@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { Subscribe ) : ( - + )} @@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { ); }; -const BillingDialog = ({ - priceId, - planName, - claim, -}: { - priceId: string; - planName: string; - memberCount: number; - claim: string; -}) => { +const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => { const [isOpen, setIsOpen] = useState(false); const { t } = useLingui(); diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts b/packages/ee/server-only/stripe/webhook/on-subscription-created.ts index 242471481..655bdbbf8 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-created.ts @@ -3,10 +3,10 @@ import { createOrganisationClaimUpsertData, } from '@documenso/lib/server-only/organisation/create-organisation'; import type { Stripe } from '@documenso/lib/server-only/stripe'; -import type { InternalClaim, StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription'; +import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription'; import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription'; import { prisma } from '@documenso/prisma'; -import { OrganisationType, SubscriptionStatus } from '@prisma/client'; +import { OrganisationType, type SubscriptionClaim, SubscriptionStatus } from '@prisma/client'; import { match } from 'ts-pattern'; import { extractStripeClaim } from './on-subscription-updated'; @@ -108,7 +108,7 @@ export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCrea type HandleOrganisationCreateOptions = { customerId: string; - claim: InternalClaim; + claim: Omit; unknownCreateData: string; }; @@ -147,7 +147,7 @@ const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData } type HandleOrganisationUpdateOptions = { customerId: string; - claim: InternalClaim; + claim: Omit; }; /** diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts index 1a2f5539a..bca54f651 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -48,7 +48,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai }, }); - const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } = + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { @@ -60,8 +60,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai const { documentMeta, user: documentOwner } = envelope; - // Don't send cancellation emails on behalf of a disabled (e.g. banned) account. - if (isOrganisationOwnerDisabled || documentOwner.disabled) { + // Don't send cancellation emails if the organisation has email sending disabled or the owner is disabled (e.g. banned). + if (emailsDisabled || documentOwner.disabled) { return; } diff --git a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts index b0e5624ba..79c6d2707 100644 --- a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts @@ -67,7 +67,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai throw new Error('Document has no recipients'); } - const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } = + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { @@ -77,8 +77,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai meta: envelope.documentMeta, }); - // Don't send completion emails on behalf of a disabled (e.g. banned) account. - if (envelope.user.disabled || isOrganisationOwnerDisabled) { + // Don't send completion emails if the organisation has email sending disabled or the owner is disabled (e.g. banned). + if (envelope.user.disabled || emailsDisabled) { return; } diff --git a/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts b/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts index 02bade62c..847278eb1 100644 --- a/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-owner-recipient-expired-email.handler.ts @@ -62,7 +62,7 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired return; } - const { branding, emailLanguage, senderEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -71,6 +71,11 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired meta: documentMeta, }); + // Don't send any emails if the organisation has email sending disabled. + if (emailsDisabled) { + return; + } + const i18n = await getI18nInstance(emailLanguage); const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`; diff --git a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts index 93519fb91..ef3b1d5c1 100644 --- a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts @@ -64,7 +64,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail return; } - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -75,8 +75,10 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail const i18n = await getI18nInstance(emailLanguage); - // Send confirmation email to the recipient who rejected - if (isRecipientEmailValidForSending(recipient)) { + // Send confirmation email to the recipient who rejected. + // Skipped when the organisation has email sending disabled, since this is sent on its behalf. + // The owner notification below intentionally uses the internal Documenso email, so it still sends. + if (!emailsDisabled && isRecipientEmailValidForSending(recipient)) { await io.runTask('send-rejection-confirmation-email', async () => { const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, { recipientName: recipient.name, 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 a3156b7d5..9cb34a9ca 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -99,7 +99,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini replyToEmail, organisationId, claims, - isOrganisationOwnerDisabled, + emailsDisabled, } = await getEmailContext({ emailType: 'RECIPIENT', source: { @@ -109,8 +109,8 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini meta: envelope.documentMeta, }); - // Don't send signing invitations on behalf of a disabled (e.g. banned) account. - if (envelope.user.disabled || isOrganisationOwnerDisabled) { + // Don't send signing invitations if the organisation has email sending disabled or the owner is disabled (e.g. banned). + if (envelope.user.disabled || emailsDisabled) { return; } diff --git a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts index 8c504a1fc..cbf69c374 100644 --- a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts +++ b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts @@ -20,6 +20,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({ cfr21: z.literal(true).optional(), hipaa: z.literal(true).optional(), signingReminders: z.literal(true).optional(), + disableEmails: z.literal(true).optional(), // Todo: Envelopes - Do we need to check? // authenticationPortal & emailDomains missing here. }), diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index b755de015..0646c5c3b 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -108,9 +108,9 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob organisationType, senderEmail, replyToEmail, - isOrganisationOwnerDisabled, organisationId, claims, + emailsDisabled, } = await getEmailContext({ emailType: 'RECIPIENT', source: { @@ -120,9 +120,10 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob meta: envelope.documentMeta, }); - // Don't send reminders on behalf of a disabled (e.g. banned) account. - if (envelope.user.disabled || isOrganisationOwnerDisabled) { - io.logger.info(`Envelope ${envelope.id} owner is disabled, skipping reminder`); + // Don't send reminders if the owner is disabled (e.g. banned) or the organisation + // has email sending disabled. + if (envelope.user.disabled || emailsDisabled) { + io.logger.info(`Envelope ${envelope.id} skipping reminder: owner disabled or organisation emails disabled`); return; } diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8011408fe..8a344c774 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -126,7 +126,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha return; } - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -187,7 +187,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; } diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index e0f119bcc..baa060408 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -151,15 +151,28 @@ 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, + } = 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({ diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/server-only/document/send-pending-email.ts index bf42ffdd0..df8550186 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/server-only/document/send-pending-email.ts @@ -47,7 +47,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 } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -56,6 +56,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) { diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 86e7ee2d7..7d2182b09 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -66,6 +66,12 @@ export type EmailContextResponse = { branding: BrandingSettings; settings: Omit; 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; senderEmail: { @@ -74,7 +80,6 @@ export type EmailContextResponse = { }; replyToEmail: string | undefined; emailLanguage: string; - isOrganisationOwnerDisabled: boolean; }; export const getEmailContext = async (options: GetEmailContextOptions): Promise => { @@ -171,9 +176,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 +228,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, }; }; diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts index ebe0d859d..71e7cc73c 100644 --- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -187,7 +187,7 @@ export const sendOrganisationMemberInviteEmail = async ({ organisationName: organisation.name, }); - const { branding, emailLanguage, senderEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, emailsDisabled } = await getEmailContext({ emailType: 'INTERNAL', source: { type: 'organisation', @@ -195,6 +195,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, diff --git a/packages/lib/server-only/organisation/create-organisation.ts b/packages/lib/server-only/organisation/create-organisation.ts index 04900814c..f77f3e376 100644 --- a/packages/lib/server-only/organisation/create-organisation.ts +++ b/packages/lib/server-only/organisation/create-organisation.ts @@ -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; }; export const createOrganisation = async ({ name, url, type, userId, customerId, claim }: CreateOrganisationOptions) => { diff --git a/packages/lib/server-only/recipient/delete-envelope-recipient.ts b/packages/lib/server-only/recipient/delete-envelope-recipient.ts index 0a50086e5..88aa411bc 100644 --- a/packages/lib/server-only/recipient/delete-envelope-recipient.ts +++ b/packages/lib/server-only/recipient/delete-envelope-recipient.ts @@ -152,14 +152,20 @@ export const deleteEnvelopeRecipient = async ({ assetBaseUrl, }); - const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + 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 diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index c09827ef5..3f6d4143e 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -85,14 +85,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 } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId, + }, + meta: envelope.documentMeta, + }); const recipientsHaveActionAuth = recipients.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, @@ -281,6 +282,7 @@ export const setDocumentRecipients = async ({ await Promise.all( removedRecipients.map(async (recipient) => { if ( + emailsDisabled || recipient.sendStatus !== SendStatus.SENT || recipient.role === RecipientRole.CC || !isRecipientRemovedEmailEnabled || diff --git a/packages/lib/server-only/subscription/get-subscription-claim.ts b/packages/lib/server-only/subscription/get-subscription-claim.ts index 694b7be84..a27e3a2f1 100644 --- a/packages/lib/server-only/subscription/get-subscription-claim.ts +++ b/packages/lib/server-only/subscription/get-subscription-claim.ts @@ -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`, }); diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index d7770fbb3..5cd3878ee 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -51,6 +51,13 @@ export const ZClaimFlagsSchema = z.object({ allowLegacyEnvelopes: z.boolean().optional(), signingReminders: z.boolean().optional(), + + /** + * Controls whether an organisation is prevented from sending emails. + * + * When this is enabled, ALL emails for the organisation are blocked. + */ + disableEmails: z.boolean().optional(), }); export type TClaimFlags = z.infer; @@ -122,6 +129,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< key: 'signingReminders', label: 'Signing reminders', }, + disableEmails: { + key: 'disableEmails', + label: 'Disable emails', + }, }; export enum INTERNAL_CLAIM_ID { @@ -133,20 +144,12 @@ export enum INTERNAL_CLAIM_ID { ENTERPRISE = 'enterprise', } -export type InternalClaim = Omit; +export type InternalClaim = Pick; export type InternalClaims = { [key in INTERNAL_CLAIM_ID]: InternalClaim; }; -/** - * TODO: THIS NEEDS A REWORK - * - * Only the values within "free" claim (flags, etc) are directly used, the rest are taken - * from the actual SubscriptionClaim in the database. - * - * We need to remove all the content besides id/name and fetch free from the database. - */ export const internalClaims: InternalClaims = { /** * Free plan has no rates and quotas since this may break self-hosters. @@ -154,135 +157,26 @@ export const internalClaims: InternalClaims = { [INTERNAL_CLAIM_ID.FREE]: { id: INTERNAL_CLAIM_ID.FREE, name: 'Free', - teamCount: 1, - memberCount: 1, - envelopeItemCount: 5, - recipientCount: 0, - locked: true, - flags: {}, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, [INTERNAL_CLAIM_ID.INDIVIDUAL]: { id: INTERNAL_CLAIM_ID.INDIVIDUAL, name: 'Individual', - teamCount: 1, - memberCount: 1, - envelopeItemCount: 5, - recipientCount: 0, - locked: true, - flags: { - unlimitedDocuments: true, - signingReminders: true, - }, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, [INTERNAL_CLAIM_ID.TEAM]: { id: INTERNAL_CLAIM_ID.TEAM, name: 'Teams', - teamCount: 1, - memberCount: 5, - envelopeItemCount: 5, - recipientCount: 0, - locked: true, - flags: { - unlimitedDocuments: true, - allowCustomBranding: true, - embedSigning: true, - signingReminders: true, - }, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, [INTERNAL_CLAIM_ID.PLATFORM]: { id: INTERNAL_CLAIM_ID.PLATFORM, name: 'Platform', - teamCount: 1, - memberCount: 0, - envelopeItemCount: 10, - recipientCount: 0, - locked: true, - flags: { - unlimitedDocuments: true, - allowCustomBranding: true, - hidePoweredBy: true, - emailDomains: false, - embedAuthoring: false, - embedAuthoringWhiteLabel: true, - embedSigning: false, - embedSigningWhiteLabel: true, - signingReminders: true, - }, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, [INTERNAL_CLAIM_ID.ENTERPRISE]: { id: INTERNAL_CLAIM_ID.ENTERPRISE, name: 'Enterprise', - teamCount: 0, - memberCount: 0, - envelopeItemCount: 10, - recipientCount: 0, - locked: true, - flags: { - unlimitedDocuments: true, - allowCustomBranding: true, - hidePoweredBy: true, - emailDomains: true, - embedAuthoring: true, - embedAuthoringWhiteLabel: true, - embedSigning: true, - embedSigningWhiteLabel: true, - cfr21: true, - authenticationPortal: true, - signingReminders: true, - }, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, [INTERNAL_CLAIM_ID.EARLY_ADOPTER]: { id: INTERNAL_CLAIM_ID.EARLY_ADOPTER, name: 'Early Adopter', - teamCount: 0, - memberCount: 0, - envelopeItemCount: 5, - recipientCount: 0, - locked: true, - flags: { - unlimitedDocuments: true, - allowCustomBranding: true, - hidePoweredBy: true, - embedSigning: true, - embedSigningWhiteLabel: true, - signingReminders: true, - }, - documentRateLimits: [], - documentQuota: null, - emailRateLimits: [], - emailQuota: null, - apiRateLimits: [], - apiQuota: null, }, } as const; From 4ee789ea378d12c85daacf7dceda80b4dec80652 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 5 Jun 2026 21:19:20 +1000 Subject: [PATCH 02/16] fix: add multi email transport system (#2942) --- .../dialogs/claim-update-dialog.tsx | 32 +- .../dialogs/email-transport-create-dialog.tsx | 95 ++++++ .../dialogs/email-transport-delete-dialog.tsx | 114 +++++++ .../email-transport-send-test-dialog.tsx | 126 +++++++ .../dialogs/email-transport-update-dialog.tsx | 104 ++++++ .../components/forms/email-transport-form.tsx | 317 ++++++++++++++++++ .../forms/subscription-claim-form.tsx | 41 +++ .../tables/admin-email-transports-table.tsx | 179 ++++++++++ .../routes/_authenticated+/admin+/_layout.tsx | 11 + .../admin+/email-transports._index.tsx | 59 ++++ .../admin+/organisations.$id.tsx | 40 +++ .../email-transport-claims.spec.ts | 268 +++++++++++++++ .../email-transport-crud.spec.ts | 284 ++++++++++++++++ ...isation-account-link-confirmation-email.ts | 11 + packages/email/transports/build-transport.ts | 51 +++ .../send-document-cancelled-emails.handler.ts | 5 +- .../send-document-completed-emails.handler.ts | 7 +- ...ated-from-direct-template-email.handler.ts | 5 +- ...ganisation-limit-exceeded-email.handler.ts | 5 +- ...rganisation-member-joined-email.handler.ts | 5 +- ...-organisation-member-left-email.handler.ts | 5 +- ...d-owner-recipient-expired-email.handler.ts | 5 +- .../send-recipient-signed-email.handler.ts | 5 +- .../emails/send-rejection-emails.handler.ts | 4 +- .../emails/send-signing-email.handler.ts | 4 +- .../admin-delete-organisation.handler.ts | 4 +- .../internal/backport-subscription-claims.ts | 4 +- .../internal/bulk-send-template.handler.ts | 5 +- .../process-signing-reminder.handler.ts | 4 +- .../2fa/email/send-2fa-token-email.ts | 5 +- .../admin/admin-super-delete-document.ts | 5 +- .../server-only/document/delete-document.ts | 5 +- .../server-only/document/resend-document.ts | 4 +- .../server-only/document/send-delete-email.ts | 5 +- .../document/send-pending-email.ts | 5 +- .../email/email-transport-config.ts | 107 ++++++ .../server-only/email/get-email-context.ts | 66 +++- .../email/resolve-email-transport.ts | 42 +++ .../create-organisation-member-invites.ts | 5 +- .../organisation/create-organisation.ts | 32 +- .../organisation/delete-organisation-email.ts | 11 +- .../recipient/delete-envelope-recipient.ts | 29 +- .../recipient/set-document-recipients.ts | 5 +- .../team/create-team-email-verification.ts | 5 +- .../lib/server-only/team/delete-team-email.ts | 5 +- packages/lib/server-only/team/delete-team.ts | 5 +- packages/lib/universal/id.ts | 1 + packages/lib/utils/organisations-claims.ts | 1 + .../migration.sql | 28 ++ packages/prisma/schema.prisma | 32 ++ .../admin-router/create-subscription-claim.ts | 2 + .../create-subscription-claim.types.ts | 2 + .../email-transport/create-email-transport.ts | 32 ++ .../create-email-transport.types.ts | 15 + .../email-transport/delete-email-transport.ts | 24 ++ .../delete-email-transport.types.ts | 7 + .../email-transport/find-email-transports.ts | 65 ++++ .../find-email-transports.types.ts | 31 ++ .../send-test-email-transport.ts | 49 +++ .../send-test-email-transport.types.ts | 10 + .../email-transport/update-email-transport.ts | 57 ++++ .../update-email-transport.types.ts | 33 ++ .../find-subscription-claims.types.ts | 1 + packages/trpc/server/admin-router/router.ts | 12 + .../update-admin-organisation.types.ts | 1 + .../admin-router/update-subscription-claim.ts | 9 +- .../update-subscription-claim.types.ts | 3 + 67 files changed, 2440 insertions(+), 115 deletions(-) create mode 100644 apps/remix/app/components/dialogs/email-transport-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-update-dialog.tsx create mode 100644 apps/remix/app/components/forms/email-transport-form.tsx create mode 100644 apps/remix/app/components/tables/admin-email-transports-table.tsx create mode 100644 apps/remix/app/routes/_authenticated+/admin+/email-transports._index.tsx create mode 100644 packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts create mode 100644 packages/app-tests/e2e/admin/email-transports/email-transport-crud.spec.ts create mode 100644 packages/email/transports/build-transport.ts create mode 100644 packages/lib/server-only/email/email-transport-config.ts create mode 100644 packages/lib/server-only/email/resolve-email-transport.ts create mode 100644 packages/prisma/migrations/20260604143030_add_email_transports/migration.sql create mode 100644 packages/trpc/server/admin-router/email-transport/create-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/delete-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/delete-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/find-email-transports.ts create mode 100644 packages/trpc/server/admin-router/email-transport/find-email-transports.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/send-test-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/send-test-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/update-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx index bcbd91a56..bdcdfbd55 100644 --- a/apps/remix/app/components/dialogs/claim-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { trpc } from '@documenso/trpc/react'; import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD const { toast } = useToast(); const [open, setOpen] = useState(false); + const [backportEmailTransport, setBackportEmailTransport] = useState(false); const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({ onSuccess: () => { @@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD await updateClaim({ id: claim.id, data, + backportEmailTransport, }) } licenseFlags={licenseFlags} formSubmitTrigger={ - - + <> +
+ setBackportEmailTransport(checked === true)} + /> + +
- -
+ + + + + + } /> diff --git a/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx new file mode 100644 index 000000000..462e41921 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx @@ -0,0 +1,95 @@ +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportCreateDialogProps = { + trigger?: React.ReactNode; +}; + +export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport created.`, + }); + + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Failed to create transport.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + await createTransport({ + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }); + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Add Email Transport + + + Fill in the details to create a new email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx new file mode 100644 index 000000000..055d920b3 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx @@ -0,0 +1,114 @@ +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type EmailTransportDeleteDialogProps = { + transportId: string; + transportName: string; + subscriptionClaimCount: number; + organisationClaimCount: number; + trigger: React.ReactNode; +}; + +export const EmailTransportDeleteDialog = ({ + transportId, + transportName, + subscriptionClaimCount, + organisationClaimCount, + trigger, +}: EmailTransportDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const isInUse = subscriptionClaimCount + organisationClaimCount > 0; + + const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport deleted.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to delete transport.`, + variant: 'destructive', + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Email Transport + + + Are you sure you want to delete the following transport? + + + + + {transportName} + + + {isInUse && ( + + + Warning, this email transport is currently being used by: + +
    + {subscriptionClaimCount > 0 && ( +
  • + +
  • + )} + + {organisationClaimCount > 0 && ( +
  • + +
  • + )} +
+
+
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx new file mode 100644 index 000000000..1a463ff72 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx @@ -0,0 +1,126 @@ +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZSendTestEmailFormSchema = z.object({ + to: z.string().email(), +}); + +type TSendTestEmailFormSchema = z.infer; + +export type EmailTransportSendTestDialogProps = { + transportId: string; + trigger: React.ReactNode; +}; + +export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({ + onSuccess: () => { + toast({ + title: t`Test email sent.`, + }); + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Test failed.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const form = useForm({ + resolver: zodResolver(ZSendTestEmailFormSchema), + defaultValues: { + to: '', + }, + }); + + const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => { + await sendTest({ id: transportId, to }); + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Send Test Email + + + Send a test email using this transport to verify the configuration. + + + +
+ +
+ ( + + + Email + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx new file mode 100644 index 000000000..5ad3ae023 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx @@ -0,0 +1,104 @@ +import { trpc } from '@documenso/trpc/react'; +import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportUpdateDialogProps = { + transport: TFindEmailTransportsResponse['data'][number]; + trigger: React.ReactNode; +}; + +export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation(); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + try { + await updateTransport({ + id: transport.id, + data: { + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }, + }); + + toast({ + title: t`Transport updated.`, + }); + + setOpen(false); + } catch { + toast({ + title: t`Failed to save transport.`, + variant: 'destructive', + }); + } + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Edit Email Transport + + + Modify the details of the email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx new file mode 100644 index 000000000..2e20fdb59 --- /dev/null +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -0,0 +1,317 @@ +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZEmailTransportFormSchema = z.object({ + name: z.string().min(1), + fromName: z.string().min(1), + fromAddress: z.string().email(), + type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), + host: z.string().optional(), + port: z.coerce.number().int().positive().optional(), + secure: z.boolean().optional(), + ignoreTLS: z.boolean().optional(), + username: z.string().optional(), + password: z.string().optional(), + service: z.string().optional(), + apiKey: z.string().optional(), + apiKeyUser: z.string().optional(), + endpoint: z.string().optional(), +}); + +export type EmailTransportFormValues = z.infer; + +type EmailTransportFormProps = { + defaultValues?: Partial; + isEdit?: boolean; + onFormSubmit: (values: EmailTransportFormValues) => Promise; + formSubmitTrigger?: React.ReactNode; +}; + +export const EmailTransportForm = ({ + defaultValues, + isEdit = false, + onFormSubmit, + formSubmitTrigger, +}: EmailTransportFormProps) => { + const { t } = useLingui(); + + const form = useForm({ + resolver: zodResolver(ZEmailTransportFormSchema), + defaultValues: { + name: '', + fromName: '', + fromAddress: '', + type: 'SMTP_AUTH', + secure: false, + ignoreTLS: false, + ...defaultValues, + }, + }); + + const type = form.watch('type'); + const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined; + + return ( +
+ +
+ ( + + + Name + + + + + + + )} + /> + +
+ ( + + + From name + + + + + + + )} + /> + + ( + + + From address + + + + + + + )} + /> +
+ + ( + + + Transport type + + + {isEdit && ( + + Transport type cannot be changed after creation. + + )} + + + )} + /> + + {(type === 'SMTP_AUTH' || type === 'SMTP_API') && ( +
+ ( + + + Host + + + + + + + )} + /> + ( + + + Port + + + + + + + )} + /> +
+ )} + + {type === 'SMTP_AUTH' && ( + <> + ( + + + Username + + + + + + + )} + /> + ( + + + Password + + + + + + + )} + /> + + )} + + {type === 'SMTP_API' && ( + ( + + + API key + + + + + + + )} + /> + )} + + {(type === 'RESEND' || type === 'MAILCHANNELS') && ( + ( + + + API key + + + + + + + )} + /> + )} + + {type === 'MAILCHANNELS' && ( + ( + + + Endpoint (optional) + + + + + + + )} + /> + )} + + {formSubmitTrigger} +
+
+ + ); +}; + +/** + * Maps flat form values to the tRPC `config` discriminated union. + */ +export const emailTransportFormToConfig = (values: EmailTransportFormValues) => { + switch (values.type) { + case 'SMTP_AUTH': + return { + type: 'SMTP_AUTH' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + ignoreTLS: values.ignoreTLS ?? false, + username: values.username || undefined, + password: values.password || undefined, + service: values.service || undefined, + }; + case 'SMTP_API': + return { + type: 'SMTP_API' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + apiKey: values.apiKey || '', + apiKeyUser: values.apiKeyUser || undefined, + }; + case 'RESEND': + return { type: 'RESEND' as const, apiKey: values.apiKey || '' }; + case 'MAILCHANNELS': + return { + type: 'MAILCHANNELS' as const, + apiKey: values.apiKey || '', + endpoint: values.endpoint || undefined, + }; + } +}; diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index e3cef8d92..1dd903a3f 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -1,5 +1,6 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -13,6 +14,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { SubscriptionClaim } from '@prisma/client'; @@ -59,9 +61,14 @@ export const SubscriptionClaimForm = ({ emailQuota: subscriptionClaim.emailQuota, apiRateLimits: subscriptionClaim.apiRateLimits, apiQuota: subscriptionClaim.apiQuota, + emailTransportId: subscriptionClaim.emailTransportId ?? null, }, }); + const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 }); + const transports = transportsData?.data ?? []; + const NONE_VALUE = '__none__'; + return (
@@ -238,6 +245,40 @@ export const SubscriptionClaimForm = ({ + ( + + + Email transport + + + + Plans without a transport use the system default mailer. + + + + )} + /> + {formSubmitTrigger} diff --git a/apps/remix/app/components/tables/admin-email-transports-table.tsx b/apps/remix/app/components/tables/admin-email-transports-table.tsx new file mode 100644 index 000000000..9b4fff07f --- /dev/null +++ b/apps/remix/app/components/tables/admin-email-transports-table.tsx @@ -0,0 +1,179 @@ +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; + +import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog'; +import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog'; +import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog'; + +export const AdminEmailTransportsTable = () => { + const { t, i18n } = useLingui(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({ + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 20, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Name`, + accessorKey: 'name', + }, + { + header: t`Type`, + accessorKey: 'type', + }, + { + header: t`From`, + cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`, + }, + { + header: t`Used by claims`, + cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims, + }, + { + header: t`Created`, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + id: 'actions', + cell: ({ row }) => ( + + + + + + + + Actions + + + e.preventDefault()}> +
+ + Edit +
+ + } + /> + + e.preventDefault()}> +
+ + Send test +
+ + } + /> + + e.preventDefault()}> +
+ + Delete +
+ + } + /> +
+
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( +
+ + + + + + + + + + + + + + + + + +
+ +
+
+ + ), + }} + > + {(table) => } +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index af00d92cb..ef3e23781 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -129,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + +