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:
@@ -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'
|
||||
}
|
||||
|
||||
+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',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Customer price is 3x our provider cost (company margin policy)
|
||||
export const EMAIL_MARGIN_MULTIPLIER = 3;
|
||||
+2
@@ -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,
|
||||
|
||||
+15
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user