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
@@ -3227,6 +3227,7 @@ enum UsageOperationType {
CODE_EXECUTION
WEB_SEARCH
CALL_RECORDING
EMAIL_SEND
}
type Mutation {
@@ -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 = {
@@ -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'
}
@@ -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',
}
@@ -0,0 +1,2 @@
// Customer price is 3x our provider cost (company margin policy)
export const EMAIL_MARGIN_MULTIPLIER = 3;
@@ -0,0 +1,2 @@
// AWS SES pricing: $0.10 / 1,000 outbound emails
export const SES_EMAIL_COST_PER_THOUSAND_DOLLARS = 0.1;
@@ -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,
@@ -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<SendEmailViaDomainOutputDTO> {
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<SendMessageCampaignOutputDTO> {
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<CampaignAudiencePreviewDTO> {
this.emailGroupAccessService.validateEmailGroupAccessOrThrow();
return this.messageCampaignService.previewAudience({
workspaceId: currentWorkspace.id,
listId: input.listId,
@@ -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<UnsubscribeTopicDTO[]> {
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<UnsubscribeTopicDTO> {
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<UnsubscribeTopicDTO> {
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<boolean> {
this.emailGroupAccessService.validateEmailGroupAccessOrThrow();
await this.unsubscribeTopicService.deleteUnsubscribeTopic(
currentWorkspace.id,
id,
@@ -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<boolean> {
return this.billingUsageService.hasAvailableCredits(workspaceId);
}
async validateEmailCreditsOrThrow(workspaceId: string): Promise<void> {
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
}
async billSentEmails({
workspaceId,
sentEmailCount,
userWorkspaceId,
}: {
workspaceId: string;
sentEmailCount: number;
userWorkspaceId?: string | null;
}): Promise<void> {
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<UsageEvent>(
USAGE_RECORDED,
[
{
resourceType: UsageResourceType.EMAIL,
operationType: UsageOperationType.EMAIL_SEND,
creditsUsedMicro,
quantity: sentEmailCount,
unit: UsageUnit.INVOCATION,
userWorkspaceId: userWorkspaceId || null,
periodStart,
},
],
workspaceId,
);
}
}
@@ -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<T extends ObjectLiteral>(
@@ -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,