Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)

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.
This commit is contained in:
neo773
2026-07-02 20:44:49 +05:30
committed by GitHub
parent a28887bba6
commit 6f64be5751
18 changed files with 288 additions and 6 deletions
@@ -22,6 +22,7 @@ const USAGE_UNIT_BY_OPERATION_TYPE: Record<UsageOperationType, UsageUnit> = {
[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,
@@ -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,
@@ -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<EmailingDomainDTO> {
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<boolean> {
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<EmailingDomainDTO> {
this.emailGroupAccessService.validateEmailGroupAccessOrThrow();
const emailingDomain =
await this.emailingDomainService.verifyEmailingDomain(
currentWorkspace,
@@ -74,6 +86,8 @@ export class EmailingDomainResolver {
async getEmailingDomains(
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDTO[]> {
this.emailGroupAccessService.validateEmailGroupAccessOrThrow();
const emailingDomains =
await this.emailingDomainService.getEmailingDomains(currentWorkspace);
@@ -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],
});
}
}
@@ -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);
}
}
}
}
@@ -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,
);
}
}
}
@@ -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, {
@@ -7,4 +7,5 @@ export enum UsageResourceType {
STORAGE = 'STORAGE',
API = 'API',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
EMAIL = 'EMAIL',
}