From 6f64be5751df5cd32278722742b7824bd5001817 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:44:49 +0530 Subject: [PATCH] Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email group (marketing email) was gated only by the `IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement. This adds real gating, split by deployment: - **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid Enterprise plan. - **Cloud** (billing enabled): metered by credits, mirroring the existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound) × 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks sends when out of credits; each email is charged after SES accepts it, in the async send job — matching how AWS bills us (no refund on bounce). Enforcement is applied at every email group resolver, and denials surface as proper client errors through a dedicated GraphQL exception filter. --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 5 +- .../src/generated-metadata/graphql.ts | 1 + .../app-billing/app-billing.service.ts | 1 + .../emailing-domain/emailing-domain.module.ts | 7 ++ .../emailing-domain.resolver.ts | 18 +++- .../email-group-access.exception.ts | 37 ++++++++ ...oup-access-graphql-api-exception.filter.ts | 29 ++++++ .../services/email-group-access.service.ts | 34 +++++++ .../usage/enums/usage-operation-type.enum.ts | 1 + .../usage/enums/usage-resource-type.enum.ts | 1 + .../constants/email-margin-multiplier.ts | 2 + .../ses-email-cost-per-thousand-dollars.ts | 2 + .../src/modules/emailing/emailing.module.ts | 8 ++ .../resolvers/emailing-send.resolver.ts | 25 +++++- .../resolvers/unsubscribe-topic.resolver.ts | 16 +++- .../services/email-billing.service.ts | 88 +++++++++++++++++++ .../services/message-campaign.service.ts | 18 ++++ 18 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/services/email-group-access.service.ts create mode 100644 packages/twenty-server/src/modules/emailing/constants/email-margin-multiplier.ts create mode 100644 packages/twenty-server/src/modules/emailing/constants/ses-email-cost-per-thousand-dollars.ts create mode 100644 packages/twenty-server/src/modules/emailing/services/email-billing.service.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 0100805752..4eca6cc521 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3227,6 +3227,7 @@ enum UsageOperationType { CODE_EXECUTION WEB_SEARCH CALL_RECORDING + EMAIL_SEND } type Mutation { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 92629dd39a..e136cc88ca 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2755,7 +2755,7 @@ export interface Query { export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'USAGE_EVENT' | 'APPLICATION_LOG' -export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH' | 'CALL_RECORDING' +export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH' | 'CALL_RECORDING' | 'EMAIL_SEND' export interface Mutation { addQueryToEventStream: Scalars['Boolean'] @@ -9276,7 +9276,8 @@ export const enumUsageOperationType = { WORKFLOW_EXECUTION: 'WORKFLOW_EXECUTION' as const, CODE_EXECUTION: 'CODE_EXECUTION' as const, WEB_SEARCH: 'WEB_SEARCH' as const, - CALL_RECORDING: 'CALL_RECORDING' as const + CALL_RECORDING: 'CALL_RECORDING' as const, + EMAIL_SEND: 'EMAIL_SEND' as const } export const enumWorkspaceMigrationActionType = { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 26d3ed0b80..b7988cd06d 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5818,6 +5818,7 @@ export enum UsageOperationType { AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN', CALL_RECORDING = 'CALL_RECORDING', CODE_EXECUTION = 'CODE_EXECUTION', + EMAIL_SEND = 'EMAIL_SEND', WEB_SEARCH = 'WEB_SEARCH', WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION' } diff --git a/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts index 2543d1eb91..bb6715fd39 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/app-billing/app-billing.service.ts @@ -22,6 +22,7 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record = { [UsageOperationType.CODE_EXECUTION]: UsageUnit.INVOCATION, [UsageOperationType.WEB_SEARCH]: UsageUnit.INVOCATION, [UsageOperationType.CALL_RECORDING]: UsageUnit.MINUTE, + [UsageOperationType.EMAIL_SEND]: UsageUnit.INVOCATION, }; // `workspaceId` + `applicationId` come from the application-access token, diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts index 489d2ff523..11f0e10a63 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module'; import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider'; import { AwsSesObservabilityService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-observability.service'; @@ -12,6 +13,7 @@ import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domai import { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service'; import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory'; import { LogEmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/log/services/log-emailing-domain-driver.service'; +import { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver'; import { EmailingDomainWorkspaceCleanupJob } from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job'; @@ -20,6 +22,7 @@ import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/s import { UnsubscribeContentService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-content.service'; import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service'; import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service'; +import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; @@ -35,14 +38,18 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac PermissionsModule, DnsManagerModule, SecretEncryptionModule, + BillingModule, + EnterpriseModule, ], exports: [ EmailingDomainService, EmailingDomainTenantStatusService, EmailingDomainDriverFactory, UnsubscribeTokenService, + EmailGroupAccessService, ], providers: [ + EmailGroupAccessService, EmailingDomainService, EmailingDomainTenantStatusService, UnsubscribeTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts index 9a2bcd67f9..84b99ece92 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards, UsePipes } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; @@ -7,6 +7,8 @@ import { FeatureFlagKey } from 'twenty-shared/types'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { CreateEmailingDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/create-emailing-domain.input'; import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto'; +import { EmailGroupAccessGraphqlApiExceptionFilter } from 'src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter'; +import { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service'; import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -23,10 +25,14 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) +@UseFilters(EmailGroupAccessGraphqlApiExceptionFilter) @UsePipes(ResolverValidationPipe) @MetadataResolver(() => EmailingDomainDTO) export class EmailingDomainResolver { - constructor(private readonly emailingDomainService: EmailingDomainService) {} + constructor( + private readonly emailingDomainService: EmailingDomainService, + private readonly emailGroupAccessService: EmailGroupAccessService, + ) {} @Mutation(() => EmailingDomainDTO) @RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED) @@ -34,6 +40,8 @@ export class EmailingDomainResolver { @Args('input') input: CreateEmailingDomainInput, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + const emailingDomain = await this.emailingDomainService.createEmailingDomain( input.domain.trim().toLowerCase(), @@ -49,6 +57,8 @@ export class EmailingDomainResolver { @Args('id') id: string, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + await this.emailingDomainService.deleteEmailingDomain(currentWorkspace, id); return true; @@ -60,6 +70,8 @@ export class EmailingDomainResolver { @Args('id') id: string, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + const emailingDomain = await this.emailingDomainService.verifyEmailingDomain( currentWorkspace, @@ -74,6 +86,8 @@ export class EmailingDomainResolver { async getEmailingDomains( @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + const emailingDomains = await this.emailingDomainService.getEmailingDomains(currentWorkspace); diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception.ts new file mode 100644 index 0000000000..ded2b0bdb8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception.ts @@ -0,0 +1,37 @@ +/* @license Enterprise */ + +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; + +import { + appendCommonExceptionCode, + CustomException, +} from 'src/utils/custom-exception'; + +export const EmailGroupAccessExceptionCode = appendCommonExceptionCode({ + EMAIL_GROUP_ENTERPRISE_PLAN_REQUIRED: 'EMAIL_GROUP_ENTERPRISE_PLAN_REQUIRED', +} as const); + +const emailGroupAccessExceptionUserFriendlyMessages: Record< + keyof typeof EmailGroupAccessExceptionCode, + MessageDescriptor +> = { + EMAIL_GROUP_ENTERPRISE_PLAN_REQUIRED: msg`Email group requires an Enterprise plan.`, + INTERNAL_SERVER_ERROR: msg`An unexpected error occurred.`, +}; + +export class EmailGroupAccessException extends CustomException< + keyof typeof EmailGroupAccessExceptionCode +> { + constructor( + message: string, + code: keyof typeof EmailGroupAccessExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + emailGroupAccessExceptionUserFriendlyMessages[code], + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..384909758a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter.ts @@ -0,0 +1,29 @@ +/* @license Enterprise */ + +import { Catch, type ExceptionFilter } from '@nestjs/common'; + +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + EmailGroupAccessException, + EmailGroupAccessExceptionCode, +} from 'src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception'; +import { + ForbiddenError, + InternalServerError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(EmailGroupAccessException) +export class EmailGroupAccessGraphqlApiExceptionFilter implements ExceptionFilter { + catch(exception: EmailGroupAccessException) { + switch (exception.code) { + case EmailGroupAccessExceptionCode.EMAIL_GROUP_ENTERPRISE_PLAN_REQUIRED: + throw new ForbiddenError(exception); + case EmailGroupAccessExceptionCode.INTERNAL_SERVER_ERROR: + throw new InternalServerError(exception); + default: { + assertUnreachable(exception.code); + } + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/email-group-access.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/email-group-access.service.ts new file mode 100644 index 0000000000..2b4e814f90 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/email-group-access.service.ts @@ -0,0 +1,34 @@ +/* @license Enterprise */ + +import { Injectable } from '@nestjs/common'; + +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; +import { + EmailGroupAccessException, + EmailGroupAccessExceptionCode, +} from 'src/engine/core-modules/emailing-domain/exceptions/email-group-access.exception'; +import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; + +// Self-hosted instances gate email group behind a valid Enterprise plan. +// Cloud instances (billing enabled) meter usage with credits at send time +// instead, so access itself is unrestricted here. +@Injectable() +export class EmailGroupAccessService { + constructor( + private readonly billingService: BillingService, + private readonly enterprisePlanService: EnterprisePlanService, + ) {} + + validateEmailGroupAccessOrThrow(): void { + if (this.billingService.isBillingEnabled()) { + return; + } + + if (!this.enterprisePlanService.isValid()) { + throw new EmailGroupAccessException( + 'Email group requires an Enterprise plan', + EmailGroupAccessExceptionCode.EMAIL_GROUP_ENTERPRISE_PLAN_REQUIRED, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts index f280707ff6..d5c5aa691d 100644 --- a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-operation-type.enum.ts @@ -9,6 +9,7 @@ export enum UsageOperationType { CODE_EXECUTION = 'CODE_EXECUTION', WEB_SEARCH = 'WEB_SEARCH', CALL_RECORDING = 'CALL_RECORDING', + EMAIL_SEND = 'EMAIL_SEND', } registerEnumType(UsageOperationType, { diff --git a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-resource-type.enum.ts b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-resource-type.enum.ts index 6c33990fcc..cea2d5a54f 100644 --- a/packages/twenty-server/src/engine/core-modules/usage/enums/usage-resource-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/usage/enums/usage-resource-type.enum.ts @@ -7,4 +7,5 @@ export enum UsageResourceType { STORAGE = 'STORAGE', API = 'API', LOGIC_FUNCTION = 'LOGIC_FUNCTION', + EMAIL = 'EMAIL', } diff --git a/packages/twenty-server/src/modules/emailing/constants/email-margin-multiplier.ts b/packages/twenty-server/src/modules/emailing/constants/email-margin-multiplier.ts new file mode 100644 index 0000000000..62d9e7f744 --- /dev/null +++ b/packages/twenty-server/src/modules/emailing/constants/email-margin-multiplier.ts @@ -0,0 +1,2 @@ +// Customer price is 3x our provider cost (company margin policy) +export const EMAIL_MARGIN_MULTIPLIER = 3; diff --git a/packages/twenty-server/src/modules/emailing/constants/ses-email-cost-per-thousand-dollars.ts b/packages/twenty-server/src/modules/emailing/constants/ses-email-cost-per-thousand-dollars.ts new file mode 100644 index 0000000000..62fb827b76 --- /dev/null +++ b/packages/twenty-server/src/modules/emailing/constants/ses-email-cost-per-thousand-dollars.ts @@ -0,0 +1,2 @@ +// AWS SES pricing: $0.10 / 1,000 outbound emails +export const SES_EMAIL_COST_PER_THOUSAND_DOLLARS = 0.1; diff --git a/packages/twenty-server/src/modules/emailing/emailing.module.ts b/packages/twenty-server/src/modules/emailing/emailing.module.ts index fa0681b353..2a2cef5ad1 100644 --- a/packages/twenty-server/src/modules/emailing/emailing.module.ts +++ b/packages/twenty-server/src/modules/emailing/emailing.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { MessageSuppressionEntity } from 'src/engine/core-modules/emailing-domain/message-suppression.entity'; @@ -10,9 +11,12 @@ import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channe import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; import { UnsubscribeController } from 'src/modules/emailing/controllers/unsubscribe.controller'; import { EmailingSendResolver } from 'src/modules/emailing/resolvers/emailing-send.resolver'; import { UnsubscribeTopicResolver } from 'src/modules/emailing/resolvers/unsubscribe-topic.resolver'; +import { EmailBillingService } from 'src/modules/emailing/services/email-billing.service'; import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service'; import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service'; import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service'; @@ -24,6 +28,9 @@ import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscri MessageChannelMetadataModule, FeatureFlagModule, PermissionsModule, + BillingModule, + WorkspaceEventEmitterModule, + WorkspaceCacheModule, TypeOrmModule.forFeature([ MessageChannelEntity, EmailingDomainEntity, @@ -33,6 +40,7 @@ import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscri ], controllers: [UnsubscribeController], providers: [ + EmailBillingService, MessageCampaignService, MessageSuppressionService, UnsubscribeTopicService, diff --git a/packages/twenty-server/src/modules/emailing/resolvers/emailing-send.resolver.ts b/packages/twenty-server/src/modules/emailing/resolvers/emailing-send.resolver.ts index 2cc7672031..c8cfa5abe1 100644 --- a/packages/twenty-server/src/modules/emailing/resolvers/emailing-send.resolver.ts +++ b/packages/twenty-server/src/modules/emailing/resolvers/emailing-send.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards, UsePipes } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; @@ -6,11 +6,13 @@ import { FeatureFlagKey } from 'twenty-shared/types'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { CampaignAudiencePreviewDTO } from 'src/engine/core-modules/emailing-domain/dtos/campaign-audience-preview.dto'; +import { EmailGroupAccessGraphqlApiExceptionFilter } from 'src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter'; import { PreviewMessageCampaignAudienceInput } from 'src/engine/core-modules/emailing-domain/dtos/preview-message-campaign-audience.input'; import { SendEmailViaDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input'; import { SendEmailViaDomainOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto'; import { SendMessageCampaignInput } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign.input'; import { SendMessageCampaignOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign-output.dto'; +import { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; @@ -21,6 +23,7 @@ import { } from 'src/engine/guards/feature-flag.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { EmailBillingService } from 'src/modules/emailing/services/email-billing.service'; import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service'; import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service'; @@ -29,12 +32,15 @@ import { MessageCampaignService } from 'src/modules/emailing/services/message-ca FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) +@UseFilters(EmailGroupAccessGraphqlApiExceptionFilter) @UsePipes(ResolverValidationPipe) @MetadataResolver() export class EmailingSendResolver { constructor( private readonly emailingDomainSenderService: EmailingDomainSenderService, private readonly messageCampaignService: MessageCampaignService, + private readonly emailGroupAccessService: EmailGroupAccessService, + private readonly emailBillingService: EmailBillingService, ) {} @Mutation(() => SendEmailViaDomainOutputDTO) @@ -43,6 +49,11 @@ export class EmailingSendResolver { @Args('input') input: SendEmailViaDomainInput, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + await this.emailBillingService.validateEmailCreditsOrThrow( + currentWorkspace.id, + ); + const { emailingDomainId, ...content } = input; const result = await this.emailingDomainSenderService.sendEmail( currentWorkspace.id, @@ -50,6 +61,11 @@ export class EmailingSendResolver { content, ); + await this.emailBillingService.billSentEmails({ + workspaceId: currentWorkspace.id, + sentEmailCount: 1, + }); + return { messageId: result.messageId }; } @@ -60,6 +76,11 @@ export class EmailingSendResolver { @AuthWorkspace() currentWorkspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + await this.emailBillingService.validateEmailCreditsOrThrow( + currentWorkspace.id, + ); + return this.messageCampaignService.send({ workspaceId: currentWorkspace.id, userWorkspaceId, @@ -77,6 +98,8 @@ export class EmailingSendResolver { @Args('input') input: PreviewMessageCampaignAudienceInput, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + return this.messageCampaignService.previewAudience({ workspaceId: currentWorkspace.id, listId: input.listId, diff --git a/packages/twenty-server/src/modules/emailing/resolvers/unsubscribe-topic.resolver.ts b/packages/twenty-server/src/modules/emailing/resolvers/unsubscribe-topic.resolver.ts index 50c2a0724e..d42d5413b7 100644 --- a/packages/twenty-server/src/modules/emailing/resolvers/unsubscribe-topic.resolver.ts +++ b/packages/twenty-server/src/modules/emailing/resolvers/unsubscribe-topic.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards, UsePipes } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; @@ -8,6 +8,8 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato import { CreateUnsubscribeTopicInput } from 'src/engine/core-modules/emailing-domain/dtos/create-unsubscribe-topic.input'; import { UnsubscribeTopicDTO } from 'src/engine/core-modules/emailing-domain/dtos/unsubscribe-topic.dto'; import { UpdateUnsubscribeTopicInput } from 'src/engine/core-modules/emailing-domain/dtos/update-unsubscribe-topic.input'; +import { EmailGroupAccessGraphqlApiExceptionFilter } from 'src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter'; +import { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service'; import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -28,6 +30,7 @@ const UNSUBSCRIBE_PREVIEW_PLACEHOLDER_EMAIL = 'preview@example.com'; FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) +@UseFilters(EmailGroupAccessGraphqlApiExceptionFilter) @UsePipes(ResolverValidationPipe) @MetadataResolver(() => UnsubscribeTopicDTO) export class UnsubscribeTopicResolver { @@ -35,6 +38,7 @@ export class UnsubscribeTopicResolver { private readonly unsubscribeTopicService: UnsubscribeTopicService, private readonly unsubscribeTokenService: UnsubscribeTokenService, private readonly twentyConfigService: TwentyConfigService, + private readonly emailGroupAccessService: EmailGroupAccessService, ) {} @Query(() => [UnsubscribeTopicDTO]) @@ -42,6 +46,8 @@ export class UnsubscribeTopicResolver { async unsubscribeTopics( @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + return this.unsubscribeTopicService.getUnsubscribeTopics( currentWorkspace.id, ); @@ -52,6 +58,8 @@ export class UnsubscribeTopicResolver { unsubscribePagePreviewUrl( @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): string { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + const token = this.unsubscribeTokenService.sign({ workspaceId: currentWorkspace.id, emailAddress: UNSUBSCRIBE_PREVIEW_PLACEHOLDER_EMAIL, @@ -67,6 +75,8 @@ export class UnsubscribeTopicResolver { @Args('input') input: CreateUnsubscribeTopicInput, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + return this.unsubscribeTopicService.createUnsubscribeTopic( currentWorkspace.id, input, @@ -79,6 +89,8 @@ export class UnsubscribeTopicResolver { @Args('input') input: UpdateUnsubscribeTopicInput, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + return this.unsubscribeTopicService.updateUnsubscribeTopic( currentWorkspace.id, input, @@ -91,6 +103,8 @@ export class UnsubscribeTopicResolver { @Args('id') id: string, @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { + this.emailGroupAccessService.validateEmailGroupAccessOrThrow(); + await this.unsubscribeTopicService.deleteUnsubscribeTopic( currentWorkspace.id, id, diff --git a/packages/twenty-server/src/modules/emailing/services/email-billing.service.ts b/packages/twenty-server/src/modules/emailing/services/email-billing.service.ts new file mode 100644 index 0000000000..b47fb22d68 --- /dev/null +++ b/packages/twenty-server/src/modules/emailing/services/email-billing.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; + +import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant'; +import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; +import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant'; +import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; +import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum'; +import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum'; +import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type'; +import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { EMAIL_MARGIN_MULTIPLIER } from 'src/modules/emailing/constants/email-margin-multiplier'; +import { SES_EMAIL_COST_PER_THOUSAND_DOLLARS } from 'src/modules/emailing/constants/ses-email-cost-per-thousand-dollars'; + +@Injectable() +export class EmailBillingService { + constructor( + private readonly workspaceEventEmitter: WorkspaceEventEmitter, + private readonly billingService: BillingService, + private readonly billingUsageService: BillingUsageService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) {} + + async hasEmailCredits(workspaceId: string): Promise { + return this.billingUsageService.hasAvailableCredits(workspaceId); + } + + async validateEmailCreditsOrThrow(workspaceId: string): Promise { + await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId); + } + + async billSentEmails({ + workspaceId, + sentEmailCount, + userWorkspaceId, + }: { + workspaceId: string; + sentEmailCount: number; + userWorkspaceId?: string | null; + }): Promise { + if (sentEmailCount <= 0) { + return; + } + + const providerCostInDollars = + (sentEmailCount / 1000) * SES_EMAIL_COST_PER_THOUSAND_DOLLARS; + const chargedInDollars = providerCostInDollars * EMAIL_MARGIN_MULTIPLIER; + const creditsUsedMicro = Math.round( + convertDollarsToBillingCredits(chargedInDollars), + ); + + let periodStart: Date | undefined; + + if (this.billingService.isBillingEnabled()) { + const { currentBillingSubscription } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'currentBillingSubscription', + ]); + + if (currentBillingSubscription !== NO_BILLING_SUBSCRIPTION) { + periodStart = currentBillingSubscription.currentPeriodStart; + + await this.billingUsageService.decrementAvailableCreditsInCache({ + workspaceId, + usedCredits: creditsUsedMicro, + }); + } + } + + this.workspaceEventEmitter.emitCustomBatchEvent( + USAGE_RECORDED, + [ + { + resourceType: UsageResourceType.EMAIL, + operationType: UsageOperationType.EMAIL_SEND, + creditsUsedMicro, + quantity: sentEmailCount, + unit: UsageUnit.INVOCATION, + userWorkspaceId: userWorkspaceId || null, + periodStart, + }, + ], + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/emailing/services/message-campaign.service.ts b/packages/twenty-server/src/modules/emailing/services/message-campaign.service.ts index f3b810566f..f63cc17683 100644 --- a/packages/twenty-server/src/modules/emailing/services/message-campaign.service.ts +++ b/packages/twenty-server/src/modules/emailing/services/message-campaign.service.ts @@ -34,6 +34,7 @@ import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspac import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; +import { EmailBillingService } from 'src/modules/emailing/services/email-billing.service'; import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service'; import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service'; import { MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity'; @@ -98,6 +99,7 @@ export class MessageCampaignService { private readonly messageQueueService: MessageQueueService, private readonly messageChannelMetadataService: MessageChannelMetadataService, private readonly messageSuppressionService: MessageSuppressionService, + private readonly emailBillingService: EmailBillingService, ) {} private getUserRepository( @@ -345,6 +347,17 @@ export class MessageCampaignService { const fromAddress = campaign.fromAddress?.primaryEmail ?? ''; const unsubscribeTopicId = campaign.unsubscribeTopicId ?? undefined; + const hasEmailCredits = + await this.emailBillingService.hasEmailCredits(workspaceId); + + if (!hasEmailCredits) { + await messageRepository.update(messageId, { + deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.SKIPPED, + }); + + return; + } + try { let result: EmailingDomainSendEmailResult; @@ -403,6 +416,11 @@ export class MessageCampaignService { text, }); + await this.emailBillingService.billSentEmails({ + workspaceId, + sentEmailCount: 1, + }); + const associationRepository = await this.getSystemRepository( workspaceId, MessageChannelMessageAssociationWorkspaceEntity,