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:
+1
@@ -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,
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+16
-2
@@ -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);
|
||||
|
||||
|
||||
+37
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user