[WIP] Feat/marketing emails (#21173)

Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.

## Model

Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.

Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.

Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.

## Sending

- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.

## Unsubscribe

- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.

## Architecture

Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.

## Frontend

- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -1,3 +1,5 @@
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
export const isEmailBlocklisted = (
channelHandle: string[],
email: string | null | undefined,
@@ -7,11 +9,15 @@ export const isEmailBlocklisted = (
return false;
}
const domain = getDomainFromEmail(email);
return blocklist.some((item) => {
if (item.startsWith('@')) {
const domain = email.split('@')[1];
const bareDomain = item.slice(1);
return domain === item.slice(1) || domain.endsWith(`.${item.slice(1)}`);
return (
domain === bareDomain || (domain?.endsWith(`.${bareDomain}`) ?? false)
);
}
return email === item;
@@ -1,9 +1,10 @@
import psl from 'psl';
import { isParsedDomain } from 'src/modules/contact-creation-manager/types/is-psl-parsed-domain.type';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
export const getDomainNameFromHandle = (handle: string): string => {
const wholeDomain = handle?.split('@')?.[1] || '';
const wholeDomain = getDomainFromEmail(handle) ?? '';
const result = psl.parse(wholeDomain);
@@ -0,0 +1,158 @@
import {
BadRequestException,
Body,
Controller,
Get,
Header,
HttpCode,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
import { type UnsubscribeTokenPayload } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-token-payload.type';
import { buildUnsubscribePreferencesPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-preferences-page.util';
import { buildUnsubscribeResultPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-result-page.util';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
const UNSUBSCRIBE_TOKEN_FORMAT = /^[A-Za-z0-9_-]{1,1024}$/;
const UPDATE_PREFERENCES_PATH = '/emailing/unsubscribe/preferences';
const UNSUBSCRIBE_ALL_PATH = '/emailing/unsubscribe/all';
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
const PREVIEW_RESULT_PAGE = buildUnsubscribeResultPage(
'Preview',
'This is a preview — no changes were saved.',
);
type UnsubscribeFormBody = {
t?: string;
unsubscribeTopicId?: string | string[];
};
@Controller('emailing/unsubscribe')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class UnsubscribeController {
constructor(
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly messageSuppressionService: MessageSuppressionService,
) {}
@Post()
@HttpCode(200)
async handleOneClickUnsubscribe(@Query('t') token: string): Promise<void> {
const payload = this.verifyTokenOrThrow(token);
if (payload.preview === true) {
return;
}
await this.messageSuppressionService.suppress({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
unsubscribeTopicId: payload.unsubscribeTopicId ?? null,
});
}
@Get()
@Header('Content-Type', HTML_CONTENT_TYPE)
async handlePreferencesPage(@Query('t') token: string): Promise<string> {
const payload = this.verifyTokenOrThrow(token);
const topics = await this.messageSuppressionService.getTopicOptOutState({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
});
return buildUnsubscribePreferencesPage({
token,
topics,
updatePath: UPDATE_PREFERENCES_PATH,
unsubscribeAllPath: UNSUBSCRIBE_ALL_PATH,
});
}
@Post('preferences')
@Header('Content-Type', HTML_CONTENT_TYPE)
async handleUpdatePreferences(
@Body() body: UnsubscribeFormBody,
): Promise<string> {
const payload = this.verifyTokenOrThrow(body.t);
if (payload.preview === true) {
return PREVIEW_RESULT_PAGE;
}
await this.messageSuppressionService.setTopicOptOuts({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
keptTopicIds: this.normalizeTopicIds(body.unsubscribeTopicId),
});
return buildUnsubscribeResultPage(
'Preferences updated',
'Your email preferences have been saved.',
);
}
@Post('all')
@Header('Content-Type', HTML_CONTENT_TYPE)
async handleUnsubscribeAll(
@Body() body: UnsubscribeFormBody,
): Promise<string> {
const payload = this.verifyTokenOrThrow(body.t);
if (payload.preview === true) {
return PREVIEW_RESULT_PAGE;
}
await this.messageSuppressionService.suppress({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
});
return buildUnsubscribeResultPage(
'You have been unsubscribed',
'You will no longer receive marketing emails from this sender.',
);
}
private normalizeTopicIds(
unsubscribeTopicId: string | string[] | undefined,
): string[] {
if (Array.isArray(unsubscribeTopicId)) {
return unsubscribeTopicId.filter(isNonEmptyString);
}
return isNonEmptyString(unsubscribeTopicId) ? [unsubscribeTopicId] : [];
}
private verifyTokenOrThrow(
token: string | undefined,
): UnsubscribeTokenPayload {
if (!isNonEmptyString(token) || !UNSUBSCRIBE_TOKEN_FORMAT.test(token)) {
throw new BadRequestException('Malformed unsubscribe token');
}
const payload = this.unsubscribeTokenService.verify(token);
if (payload === null) {
throw new BadRequestException('Invalid unsubscribe token');
}
return payload;
}
}
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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';
import { UnsubscribeTopicEntity } from 'src/engine/core-modules/emailing-domain/unsubscribe-topic.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
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 { 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 { 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';
import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscribe-topic.service';
@Module({
imports: [
EmailingDomainModule,
MessageChannelMetadataModule,
FeatureFlagModule,
PermissionsModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
EmailingDomainEntity,
MessageSuppressionEntity,
UnsubscribeTopicEntity,
]),
],
controllers: [UnsubscribeController],
providers: [
MessageCampaignService,
MessageSuppressionService,
UnsubscribeTopicService,
EmailingDomainSenderService,
EmailingSendResolver,
UnsubscribeTopicResolver,
provideWorkspaceScopedRepository(EmailingDomainEntity),
provideWorkspaceScopedRepository(MessageSuppressionEntity),
provideWorkspaceScopedRepository(UnsubscribeTopicEntity),
],
exports: [
EmailingDomainSenderService,
MessageCampaignService,
MessageSuppressionService,
UnsubscribeTopicService,
],
})
export class EmailingModule {}
@@ -0,0 +1,18 @@
import { MATERIALIZE_CAMPAIGN_JOB } from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
import { type MaterializeCampaignJobData } from 'src/engine/core-modules/emailing-domain/types/materialize-campaign-job-data.type';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service';
@Processor(MessageQueue.emailQueue)
export class MaterializeCampaignJob {
constructor(
private readonly messageCampaignService: MessageCampaignService,
) {}
@Process(MATERIALIZE_CAMPAIGN_JOB)
async handle(data: MaterializeCampaignJobData): Promise<void> {
await this.messageCampaignService.processMaterializeJob(data);
}
}
@@ -0,0 +1,18 @@
import { SEND_CAMPAIGN_EMAIL_JOB } from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service';
import { type SendCampaignEmailJobData } from 'src/engine/core-modules/emailing-domain/types/send-campaign-email-job-data.type';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Processor(MessageQueue.emailQueue)
export class SendCampaignEmailJob {
constructor(
private readonly messageCampaignService: MessageCampaignService,
) {}
@Process(SEND_CAMPAIGN_EMAIL_JOB)
async handle(data: SendCampaignEmailJobData): Promise<void> {
await this.messageCampaignService.processSendJob(data);
}
}
@@ -0,0 +1,86 @@
import { UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { 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 { 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';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} 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 { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service';
import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service';
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
export class EmailingSendResolver {
constructor(
private readonly emailingDomainSenderService: EmailingDomainSenderService,
private readonly messageCampaignService: MessageCampaignService,
) {}
@Mutation(() => SendEmailViaDomainOutputDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async sendEmailViaEmailingDomain(
@Args('input') input: SendEmailViaDomainInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<SendEmailViaDomainOutputDTO> {
const { emailingDomainId, ...content } = input;
const result = await this.emailingDomainSenderService.sendEmail(
currentWorkspace.id,
emailingDomainId,
content,
);
return { messageId: result.messageId };
}
@Mutation(() => SendMessageCampaignOutputDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async sendMessageCampaign(
@Args('input') input: SendMessageCampaignInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<SendMessageCampaignOutputDTO> {
return this.messageCampaignService.send({
workspaceId: currentWorkspace.id,
userWorkspaceId,
unsubscribeTopicId: input.unsubscribeTopicId,
listId: input.listId,
subject: input.subject,
html: input.body,
fromAddress: input.fromAddress,
});
}
@Query(() => CampaignAudiencePreviewDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async previewMessageCampaignAudience(
@Args('input') input: PreviewMessageCampaignAudienceInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<CampaignAudiencePreviewDTO> {
return this.messageCampaignService.previewAudience({
workspaceId: currentWorkspace.id,
listId: input.listId,
unsubscribeTopicId: input.unsubscribeTopicId,
});
}
}
@@ -0,0 +1,101 @@
import { UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
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 { 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';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} 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 { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscribe-topic.service';
const UNSUBSCRIBE_PREVIEW_PLACEHOLDER_EMAIL = 'preview@example.com';
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@UsePipes(ResolverValidationPipe)
@MetadataResolver(() => UnsubscribeTopicDTO)
export class UnsubscribeTopicResolver {
constructor(
private readonly unsubscribeTopicService: UnsubscribeTopicService,
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Query(() => [UnsubscribeTopicDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async unsubscribeTopics(
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<UnsubscribeTopicDTO[]> {
return this.unsubscribeTopicService.getUnsubscribeTopics(
currentWorkspace.id,
);
}
@Query(() => String)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
unsubscribePagePreviewUrl(
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): string {
const token = this.unsubscribeTokenService.sign({
workspaceId: currentWorkspace.id,
emailAddress: UNSUBSCRIBE_PREVIEW_PLACEHOLDER_EMAIL,
preview: true,
});
return `${this.twentyConfigService.get('SERVER_URL')}/emailing/unsubscribe?t=${token}`;
}
@Mutation(() => UnsubscribeTopicDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async createUnsubscribeTopic(
@Args('input') input: CreateUnsubscribeTopicInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<UnsubscribeTopicDTO> {
return this.unsubscribeTopicService.createUnsubscribeTopic(
currentWorkspace.id,
input,
);
}
@Mutation(() => UnsubscribeTopicDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async updateUnsubscribeTopic(
@Args('input') input: UpdateUnsubscribeTopicInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<UnsubscribeTopicDTO> {
return this.unsubscribeTopicService.updateUnsubscribeTopic(
currentWorkspace.id,
input,
);
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async deleteUnsubscribeTopic(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<boolean> {
await this.unsubscribeTopicService.deleteUnsubscribeTopic(
currentWorkspace.id,
id,
);
return true;
}
}
@@ -0,0 +1,277 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { MessageChannelType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { EMPTY_UNSUBSCRIBE_CONTENT } from 'src/engine/core-modules/emailing-domain/constants/empty-unsubscribe-content.constant';
import {
EmailingDomainDriverException,
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import { type EmailingDomainEmailContent } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-email-content.type';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type DeliverableRecipients } from 'src/engine/core-modules/emailing-domain/types/deliverable-recipients.type';
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
import { buildUnsubscribeHeaders } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-headers.util';
import { buildUnsubscribeHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-html-footer.util';
import { buildUnsubscribeTextFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-text-footer.util';
import { buildUnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-urls.util';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
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 { isDefined } from 'twenty-shared/utils';
@Injectable()
export class EmailingDomainSenderService {
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
private readonly messageSuppressionService: MessageSuppressionService,
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
) {}
async sendEmail(
workspaceId: string,
emailingDomainId: string,
emailContent: EmailingDomainEmailContent,
): Promise<EmailingDomainSendEmailResult> {
const emailingDomain = await this.findEmailingDomainByIdOrThrow(
workspaceId,
emailingDomainId,
);
this.assertDomainCanSend(emailingDomain, emailContent.from);
const recipients = await this.selectDeliverableRecipients(
workspaceId,
emailingDomain,
emailContent,
);
const unsubscribe = this.buildUnsubscribeContent(
workspaceId,
emailingDomain,
recipients.to[0],
emailContent.unsubscribeTopicId,
);
const replyTo = await this.resolveReplyTo(workspaceId, emailContent);
const emailToSend = {
workspaceId,
domain: emailingDomain.domain,
from: emailContent.from,
replyTo,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject: emailContent.subject,
text: `${emailContent.text}${unsubscribe.textFooter}`,
html: isNonEmptyString(emailContent.html)
? `${emailContent.html}${unsubscribe.htmlFooter}`
: emailContent.html,
attachments: emailContent.attachments,
headers: [...(emailContent.headers ?? []), ...unsubscribe.headers],
} as EmailingDomainSendEmailInput;
return this.emailingDomainDriverFactory
.getCurrentDriver()
.sendEmail(emailToSend);
}
private async resolveReplyTo(
workspaceId: string,
emailContent: EmailingDomainEmailContent,
): Promise<string[] | undefined> {
if (isDefined(emailContent.replyTo) && emailContent.replyTo.length > 0) {
return emailContent.replyTo;
}
const emailGroupChannel = await this.messageChannelRepository.findOne({
where: {
workspaceId,
type: MessageChannelType.EMAIL_GROUP,
connectedAccount: { handle: emailContent.from },
},
relations: { connectedAccount: true },
});
const forwardingAddress = emailGroupChannel?.handle;
return isNonEmptyString(forwardingAddress)
? [forwardingAddress]
: undefined;
}
private async findEmailingDomainByIdOrThrow(
workspaceId: string,
emailingDomainId: string,
): Promise<EmailingDomainEntity> {
const emailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { id: emailingDomainId } },
);
if (!isDefined(emailingDomain)) {
throw new EmailingDomainDriverException(
'Emailing domain not found',
EmailingDomainDriverExceptionCode.NOT_FOUND,
);
}
return emailingDomain;
}
private assertDomainCanSend(
emailingDomain: EmailingDomainEntity,
fromAddress: string,
): void {
if (emailingDomain.status !== EmailingDomainStatus.VERIFIED) {
throw new EmailingDomainDriverException(
`Emailing domain is not verified (status: ${emailingDomain.status})`,
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
);
}
if (emailingDomain.tenantStatus !== EmailingDomainTenantStatus.ACTIVE) {
throw new EmailingDomainDriverException(
`Sending is suspended for emailing domain ${emailingDomain.domain} (tenantStatus: ${emailingDomain.tenantStatus})`,
EmailingDomainDriverExceptionCode.SENDING_SUSPENDED,
);
}
const fromAddressDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
if (fromAddressDomain !== emailingDomain.domain.toLowerCase()) {
throw new EmailingDomainDriverException(
`From address ${fromAddress} does not match verified domain ${emailingDomain.domain}`,
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
);
}
}
private async selectDeliverableRecipients(
workspaceId: string,
emailingDomain: EmailingDomainEntity,
emailContent: EmailingDomainEmailContent,
): Promise<DeliverableRecipients> {
const allRecipients = [
...emailContent.to,
...(emailContent.cc ?? []),
...(emailContent.bcc ?? []),
];
const suppressedAddresses =
await this.messageSuppressionService.getSuppressedAddresses(
workspaceId,
allRecipients,
);
const listUnsubscribedAddresses = await this.getListUnsubscribedAddresses(
workspaceId,
allRecipients,
emailContent.unsubscribeTopicId,
);
const isDeliverable = (address: string): boolean => {
const normalizedAddress = address.trim().toLowerCase();
return (
!suppressedAddresses.has(normalizedAddress) &&
!listUnsubscribedAddresses.has(normalizedAddress)
);
};
const to = emailContent.to.filter(isDeliverable);
if (to.length === 0) {
throw new EmailingDomainDriverException(
`All primary recipients are suppressed for emailing domain ${emailingDomain.domain}`,
EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED,
);
}
return {
to,
cc: emailContent.cc?.filter(isDeliverable),
bcc: emailContent.bcc?.filter(isDeliverable),
};
}
private async getListUnsubscribedAddresses(
workspaceId: string,
recipients: string[],
unsubscribeTopicId: string | undefined,
): Promise<Set<string>> {
if (!isNonEmptyString(unsubscribeTopicId)) {
return new Set();
}
return this.messageSuppressionService.getTopicSuppressedAddresses(
workspaceId,
recipients,
unsubscribeTopicId,
);
}
private buildUnsubscribeContent(
workspaceId: string,
emailingDomain: EmailingDomainEntity,
primaryRecipient: string,
unsubscribeTopicId: string | undefined,
): UnsubscribeContent {
const isDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
if (isDemoMode) {
return EMPTY_UNSUBSCRIBE_CONTENT;
}
if (
emailingDomain.unsubscribeHostnameStatus !==
UnsubscribeHostnameStatus.ACTIVE ||
!isNonEmptyString(emailingDomain.unsubscribeHostname)
) {
throw new EmailingDomainDriverException(
`Cannot send email for ${emailingDomain.domain}: unsubscribe domain is not active (status: ${emailingDomain.unsubscribeHostnameStatus})`,
EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY,
);
}
const token = this.unsubscribeTokenService.sign({
workspaceId,
emailAddress: primaryRecipient,
...(isNonEmptyString(unsubscribeTopicId) ? { unsubscribeTopicId } : {}),
});
const unsubscribeUrls = buildUnsubscribeUrls({
unsubscribeHostname: emailingDomain.unsubscribeHostname,
domain: emailingDomain.domain,
token,
});
return {
headers: buildUnsubscribeHeaders(unsubscribeUrls),
textFooter: buildUnsubscribeTextFooter(unsubscribeUrls.httpsUrl),
htmlFooter: buildUnsubscribeHtmlFooter(unsubscribeUrls.httpsUrl),
};
}
}
@@ -0,0 +1,762 @@
import { Injectable, Logger, type Type } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { In, type ObjectLiteral } from 'typeorm';
import { v4, v5 } from 'uuid';
import {
CAMPAIGN_MESSAGE_DELIVERY_STATUS,
CAMPAIGN_MESSAGE_ID_NAMESPACE,
CAMPAIGN_STATUS,
MATERIALIZE_CAMPAIGN_JOB,
MAX_CAMPAIGN_RECIPIENTS,
SEND_CAMPAIGN_EMAIL_JOB,
} from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
import {
EmailingDomainDriverException,
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { type CampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/campaign-recipient.type';
import { type CampaignSkippedBreakdown } from 'src/engine/core-modules/emailing-domain/types/campaign-skipped-breakdown.type';
import { type MaterializeCampaignJobData } from 'src/engine/core-modules/emailing-domain/types/materialize-campaign-job-data.type';
import { type RawCampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/raw-campaign-recipient.type';
import { type SendCampaignEmailJobData } from 'src/engine/core-modules/emailing-domain/types/send-campaign-email-job-data.type';
import { normalizeCampaignRecipients } from 'src/engine/core-modules/emailing-domain/utils/normalize-campaign-recipients.util';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
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 { 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';
import { MessageListMemberWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list-member.workspace-entity';
import { renderCampaignTemplate } from 'src/modules/emailing/utils/render-campaign-template.util';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { createHtmlToTextConverter } from 'src/modules/messaging/message-import-manager/utils/create-html-to-text-converter.util';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { MessageParticipantRole } from 'twenty-shared/types';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
type SendCampaignArgs = {
workspaceId: string;
userWorkspaceId: string;
listId: string;
subject: string;
html: string;
fromAddress: string;
unsubscribeTopicId?: string;
};
type SendCampaignResult = {
campaignId: string;
queuedCount: number;
skipped: CampaignSkippedBreakdown;
};
type CampaignAudiencePreview = {
totalMembers: number;
withoutEmail: number;
duplicateEmails: number;
globallyUnsubscribed: number;
topicUnsubscribed: number;
sendable: number;
};
type CampaignMessageRecipient = CampaignRecipient & { messageId: string };
const toRawRecipient = (person: {
id: string;
emails?: { primaryEmail?: string | null } | null;
}): RawCampaignRecipient => ({
personId: person.id,
email: person.emails?.primaryEmail ?? null,
});
@Injectable()
export class MessageCampaignService {
private readonly logger = new Logger(MessageCampaignService.name);
private readonly htmlToText = createHtmlToTextConverter();
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainSenderService: EmailingDomainSenderService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectMessageQueue(MessageQueue.emailQueue)
private readonly messageQueueService: MessageQueueService,
private readonly messageChannelMetadataService: MessageChannelMetadataService,
private readonly messageSuppressionService: MessageSuppressionService,
) {}
private getUserRepository<T extends ObjectLiteral>(
workspaceId: string,
entity: Type<T>,
) {
return this.globalWorkspaceOrmManager.getRepository(workspaceId, entity);
}
private getSystemRepository<T extends ObjectLiteral>(
workspaceId: string,
entity: Type<T>,
) {
return this.globalWorkspaceOrmManager.getRepository(workspaceId, entity, {
shouldBypassPermissionChecks: true,
});
}
async send({
workspaceId,
userWorkspaceId,
unsubscribeTopicId,
subject,
html,
fromAddress,
listId,
}: SendCampaignArgs): Promise<SendCampaignResult> {
const fromDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
const emailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { domain: fromDomain, status: EmailingDomainStatus.VERIFIED } },
);
if (emailingDomain === null) {
throw new Error(
`No verified emailing domain matches the from address ${fromAddress}`,
);
}
const { campaignId, recipients, skipped } =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const rawRecipients = await this.resolveRecipientsFromList(
workspaceId,
listId,
);
const normalized = normalizeCampaignRecipients(
rawRecipients,
MAX_CAMPAIGN_RECIPIENTS,
);
const newCampaignId = await this.createCampaign({
workspaceId,
subject,
html,
fromAddress,
unsubscribeTopicId,
listId,
});
return {
campaignId: newCampaignId,
recipients: normalized.recipients,
skipped: normalized.skipped,
};
},
);
const messageChannel =
await this.messageChannelMetadataService.getOrCreateEmailGroupChannel({
fromAddress,
userWorkspaceId,
workspaceId,
});
await this.messageQueueService.add<MaterializeCampaignJobData>(
MATERIALIZE_CAMPAIGN_JOB,
{
workspaceId,
campaignId,
messageChannelId: messageChannel.id,
emailingDomainId: emailingDomain.id,
recipients,
},
{ retryLimit: 3 },
);
return { campaignId, queuedCount: recipients.length, skipped };
}
async processMaterializeJob(data: MaterializeCampaignJobData): Promise<void> {
const {
workspaceId,
campaignId,
messageChannelId,
emailingDomainId,
recipients,
} = data;
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const campaignRepository = await this.getSystemRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
);
const campaign = await campaignRepository.findOne({
where: { id: campaignId },
});
if (campaign === null) {
return;
}
const recipientsByMessageId = new Map<string, CampaignMessageRecipient>();
for (const recipient of recipients) {
const messageId = this.campaignMessageId(
campaignId,
recipient.personId,
);
if (!recipientsByMessageId.has(messageId)) {
recipientsByMessageId.set(messageId, { ...recipient, messageId });
}
}
const allRecipients = [...recipientsByMessageId.values()];
const messageRepository = await this.getSystemRepository(
workspaceId,
MessageWorkspaceEntity,
);
const existingMessages = await messageRepository.find({
where: { messageCampaignId: campaignId },
select: { id: true },
});
const existingMessageIds = new Set(
existingMessages.map((message) => message.id),
);
const recipientsToCreate = allRecipients.filter(
(recipient) => !existingMessageIds.has(recipient.messageId),
);
if (recipientsToCreate.length > 0) {
await this.materializeCampaignMessages({
workspaceId,
campaignId,
messageChannelId,
fromAddress: campaign.fromAddress?.primaryEmail ?? '',
subjectTemplate: campaign.subject ?? '',
bodyTemplate: campaign.bodyTemplate ?? '',
recipients: recipientsToCreate,
});
}
for (const recipient of allRecipients) {
await this.messageQueueService.add<SendCampaignEmailJobData>(
SEND_CAMPAIGN_EMAIL_JOB,
{
workspaceId,
campaignId,
messageId: recipient.messageId,
personId: recipient.personId,
recipientEmail: recipient.email,
emailingDomainId,
},
{ retryLimit: 3 },
);
}
await this.finalizeCampaignIfComplete(workspaceId, campaignId);
}, buildSystemAuthContext(workspaceId));
}
async processSendJob(data: SendCampaignEmailJobData): Promise<void> {
const {
workspaceId,
campaignId,
messageId,
personId,
recipientEmail,
emailingDomainId,
} = data;
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageRepository = await this.getSystemRepository(
workspaceId,
MessageWorkspaceEntity,
);
const message = await messageRepository.findOne({
where: { id: messageId },
});
if (
message === null ||
(message.deliveryStatus !== CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED &&
message.deliveryStatus !== CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED)
) {
return;
}
const campaignRepository = await this.getSystemRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
);
const campaign = await campaignRepository.findOne({
where: { id: campaignId },
});
if (campaign === null) {
return;
}
const personRepository = await this.getSystemRepository(
workspaceId,
PersonWorkspaceEntity,
);
const person = await personRepository.findOne({
where: { id: personId },
});
const variables = this.buildTemplateVariables(person);
const subject = renderCampaignTemplate(
campaign.subject ?? '',
variables,
{
escapeValues: false,
},
);
const html = renderCampaignTemplate(
campaign.bodyTemplate ?? '',
variables,
{
escapeValues: true,
},
);
const text = this.htmlToText(html);
const fromAddress = campaign.fromAddress?.primaryEmail ?? '';
const unsubscribeTopicId = campaign.unsubscribeTopicId ?? undefined;
try {
let result: EmailingDomainSendEmailResult;
try {
result = await this.emailingDomainSenderService.sendEmail(
workspaceId,
emailingDomainId,
{
from: fromAddress,
to: [recipientEmail],
subject,
text,
html,
unsubscribeTopicId,
},
);
} catch (error) {
const code =
error instanceof EmailingDomainDriverException ? error.code : null;
if (
code === EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED
) {
await messageRepository.update(messageId, {
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.SKIPPED,
});
return;
}
await messageRepository.update(messageId, {
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED,
});
this.logger.warn(
`Campaign ${campaignId} send failed for ${recipientEmail}: ${
error instanceof Error ? error.message : String(error)
}`,
);
const isRetryable =
code === null ||
code === EmailingDomainDriverExceptionCode.TEMPORARY_ERROR ||
code === EmailingDomainDriverExceptionCode.UNKNOWN;
if (isRetryable) {
throw error;
}
return;
}
await messageRepository.update(messageId, {
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.SENT,
headerMessageId: result.messageId,
subject,
text,
});
const associationRepository = await this.getSystemRepository(
workspaceId,
MessageChannelMessageAssociationWorkspaceEntity,
);
await associationRepository.update(
{ messageId },
{
messageExternalId: result.messageId,
messageThreadExternalId: result.messageId,
},
);
} finally {
await this.finalizeCampaignIfComplete(workspaceId, campaignId);
}
}, buildSystemAuthContext(workspaceId));
}
async recordDeliveryFailureByProviderMessageId({
workspaceId,
providerMessageId,
deliveryStatus,
}: {
workspaceId: string;
providerMessageId: string;
deliveryStatus: string;
}): Promise<void> {
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageRepository = await this.getSystemRepository(
workspaceId,
MessageWorkspaceEntity,
);
const message = await messageRepository.findOne({
where: { headerMessageId: providerMessageId },
});
if (message === null || message.messageCampaignId === null) {
return;
}
if (
message.deliveryStatus === CAMPAIGN_MESSAGE_DELIVERY_STATUS.BOUNCED ||
message.deliveryStatus === CAMPAIGN_MESSAGE_DELIVERY_STATUS.COMPLAINED
) {
return;
}
await messageRepository.update(message.id, { deliveryStatus });
}, buildSystemAuthContext(workspaceId));
}
private async createCampaign({
workspaceId,
subject,
html,
fromAddress,
unsubscribeTopicId,
listId,
}: {
workspaceId: string;
subject: string;
html: string;
fromAddress: string;
unsubscribeTopicId?: string;
listId: string;
}): Promise<string> {
const campaignRepository = await this.getUserRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
);
const { identifiers } = await campaignRepository.insert({
subject,
bodyTemplate: html,
fromAddress: { primaryEmail: fromAddress, additionalEmails: null },
status: CAMPAIGN_STATUS.SENDING,
unsubscribeTopicId: unsubscribeTopicId ?? null,
listId,
});
return identifiers[0].id;
}
private async materializeCampaignMessages({
workspaceId,
campaignId,
messageChannelId,
fromAddress,
subjectTemplate,
bodyTemplate,
recipients,
}: {
workspaceId: string;
campaignId: string;
messageChannelId: string;
fromAddress: string;
subjectTemplate: string;
bodyTemplate: string;
recipients: CampaignMessageRecipient[];
}): Promise<void> {
const now = new Date();
const text = this.htmlToText(bodyTemplate);
const rows = recipients.map((recipient) => ({
recipient,
messageId: recipient.messageId,
threadId: v4(),
temporaryExternalId: v4(),
}));
const messageThreadRepository = await this.getSystemRepository(
workspaceId,
MessageThreadWorkspaceEntity,
);
const messageRepository = await this.getSystemRepository(
workspaceId,
MessageWorkspaceEntity,
);
const associationRepository = await this.getSystemRepository(
workspaceId,
MessageChannelMessageAssociationWorkspaceEntity,
);
const participantRepository = await this.getSystemRepository(
workspaceId,
MessageParticipantWorkspaceEntity,
);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
if (!workspaceDataSource) {
throw new Error(
`No workspace datasource available for workspace ${workspaceId}`,
);
}
await workspaceDataSource.transaction(
async (transactionManager: WorkspaceEntityManager) => {
await messageThreadRepository.insert(
rows.map((row) => ({ id: row.threadId })),
transactionManager,
);
await messageRepository.insert(
rows.map((row) => ({
id: row.messageId,
headerMessageId: row.temporaryExternalId,
subject: subjectTemplate,
text,
receivedAt: now,
messageThreadId: row.threadId,
messageCampaignId: campaignId,
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED,
})),
transactionManager,
);
await associationRepository.insert(
rows.map((row) => ({
id: v4(),
messageId: row.messageId,
messageChannelId,
messageExternalId: row.temporaryExternalId,
messageThreadExternalId: row.temporaryExternalId,
direction: MessageDirection.OUTGOING,
})),
transactionManager,
);
await participantRepository.insert(
rows.flatMap((row) => [
{
id: v4(),
messageId: row.messageId,
role: MessageParticipantRole.FROM,
handle: fromAddress,
displayName: fromAddress,
},
{
id: v4(),
messageId: row.messageId,
role: MessageParticipantRole.TO,
handle: row.recipient.email,
displayName: row.recipient.email,
personId: row.recipient.personId,
messageCampaignId: campaignId,
},
]),
transactionManager,
);
},
);
}
private async finalizeCampaignIfComplete(
workspaceId: string,
campaignId: string,
): Promise<void> {
const messageRepository = await this.getSystemRepository(
workspaceId,
MessageWorkspaceEntity,
);
const queuedCount = await messageRepository.count({
where: {
messageCampaignId: campaignId,
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED,
},
});
if (queuedCount > 0) {
return;
}
const failedCount = await messageRepository.count({
where: {
messageCampaignId: campaignId,
deliveryStatus: CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED,
},
});
const campaignRepository = await this.getSystemRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
);
await campaignRepository.update(
{ id: campaignId, status: CAMPAIGN_STATUS.SENDING },
{
status:
failedCount > 0
? CAMPAIGN_STATUS.SENT_WITH_ERRORS
: CAMPAIGN_STATUS.SENT,
sentAt: new Date(),
},
);
}
async previewAudience({
workspaceId,
listId,
unsubscribeTopicId,
}: {
workspaceId: string;
listId: string;
unsubscribeTopicId?: string;
}): Promise<CampaignAudiencePreview> {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const rawRecipients = await this.resolveRecipientsFromList(
workspaceId,
listId,
);
const totalMembers = rawRecipients.length;
const { recipients, skipped } = normalizeCampaignRecipients(
rawRecipients,
MAX_CAMPAIGN_RECIPIENTS,
);
const emails = recipients.map((recipient) => recipient.email);
const globallySuppressed =
await this.messageSuppressionService.getSuppressedAddresses(
workspaceId,
emails,
);
const topicSuppressed = isNonEmptyString(unsubscribeTopicId)
? await this.messageSuppressionService.getTopicSuppressedAddresses(
workspaceId,
emails,
unsubscribeTopicId,
)
: new Set<string>();
let globallyUnsubscribed = 0;
let topicUnsubscribed = 0;
let sendable = 0;
for (const recipient of recipients) {
const normalizedEmail = recipient.email.trim().toLowerCase();
if (globallySuppressed.has(normalizedEmail)) {
globallyUnsubscribed += 1;
} else if (topicSuppressed.has(normalizedEmail)) {
topicUnsubscribed += 1;
} else {
sendable += 1;
}
}
return {
totalMembers,
withoutEmail: skipped.noEmail,
duplicateEmails: skipped.deduped,
globallyUnsubscribed,
topicUnsubscribed,
sendable,
};
},
);
}
private async resolveRecipientsFromList(
workspaceId: string,
listId: string,
): Promise<RawCampaignRecipient[]> {
const listMemberRepository = await this.getUserRepository(
workspaceId,
MessageListMemberWorkspaceEntity,
);
const members = await listMemberRepository.find({
where: { listId },
});
return this.loadRecipientsByPersonIds(
workspaceId,
members.map((member) => member.personId),
);
}
private async loadRecipientsByPersonIds(
workspaceId: string,
personIds: string[],
): Promise<RawCampaignRecipient[]> {
if (personIds.length === 0) {
return [];
}
const personRepository = await this.getUserRepository(
workspaceId,
PersonWorkspaceEntity,
);
const people = await personRepository.find({
where: { id: In(personIds) },
});
return people.map(toRawRecipient);
}
private buildTemplateVariables(
person: PersonWorkspaceEntity | null,
): Record<string, string> {
const firstName = person?.name?.firstName ?? '';
const lastName = person?.name?.lastName ?? '';
return {
firstName,
lastName,
fullName: [firstName, lastName].filter(Boolean).join(' '),
email: person?.emails?.primaryEmail ?? '',
};
}
private campaignMessageId(campaignId: string, personId: string): string {
return v5(`${campaignId}:${personId}`, CAMPAIGN_MESSAGE_ID_NAMESPACE);
}
}
@@ -0,0 +1,277 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { In, IsNull, QueryFailedError } from 'typeorm';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { type QueryFailedErrorWithCode } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import {
GLOBAL_BLOCKING_SUPPRESSION_REASONS,
HARD_SUPPRESSION_REASONS,
} from 'src/engine/core-modules/emailing-domain/constants/hard-suppression-reasons.constant';
import { MessageSuppressionEntity } from 'src/engine/core-modules/emailing-domain/message-suppression.entity';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
import { type TopicOptOutState } from 'src/engine/core-modules/emailing-domain/types/topic-opt-out-state.type';
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 { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscribe-topic.service';
type SuppressArgs = {
workspaceId: string;
emailAddress: string;
reason: MessageSuppressionReason;
source: MessageSuppressionSource;
providerEventId?: string | null;
unsubscribeTopicId?: string | null;
};
type TopicOptOutStateArgs = {
workspaceId: string;
emailAddress: string;
};
type SetTopicOptOutsArgs = {
workspaceId: string;
emailAddress: string;
keptTopicIds: string[];
};
@Injectable()
export class MessageSuppressionService {
constructor(
@InjectWorkspaceScopedRepository(MessageSuppressionEntity)
private readonly suppressionRepository: WorkspaceScopedRepository<MessageSuppressionEntity>,
private readonly unsubscribeTopicService: UnsubscribeTopicService,
) {}
async getSuppressedAddresses(
workspaceId: string,
emailAddresses: string[],
): Promise<Set<string>> {
const normalizedAddresses = this.normalizeAddresses(emailAddresses);
if (!isNonEmptyArray(normalizedAddresses)) {
return new Set();
}
const suppressions = await this.suppressionRepository.find(workspaceId, {
where: {
emailAddress: In(normalizedAddresses),
reason: In(GLOBAL_BLOCKING_SUPPRESSION_REASONS),
unsubscribeTopicId: IsNull(),
},
});
return new Set(suppressions.map((suppression) => suppression.emailAddress));
}
async getTopicSuppressedAddresses(
workspaceId: string,
emailAddresses: string[],
unsubscribeTopicId: string,
): Promise<Set<string>> {
const normalizedAddresses = this.normalizeAddresses(emailAddresses);
if (
!isNonEmptyArray(normalizedAddresses) ||
!isNonEmptyString(unsubscribeTopicId)
) {
return new Set();
}
const suppressions = await this.suppressionRepository.find(workspaceId, {
where: {
emailAddress: In(normalizedAddresses),
reason: MessageSuppressionReason.UNSUBSCRIBE,
unsubscribeTopicId,
},
});
return new Set(suppressions.map((suppression) => suppression.emailAddress));
}
async suppress({
workspaceId,
emailAddress,
reason,
source,
providerEventId = null,
unsubscribeTopicId = null,
}: SuppressArgs): Promise<void> {
const normalizedEmailAddress = this.normalizeEmailAddress(emailAddress);
if (!isNonEmptyString(normalizedEmailAddress)) {
return;
}
// Hard suppressions (delivery failures) are inherently address-level: a
// topic-scoped BOUNCE/COMPLAINT row would block nothing (reads filter
// per-topic rows to UNSUBSCRIBE) while occupying the (address, topic) slot.
const effectiveTopicId = HARD_SUPPRESSION_REASONS.includes(reason)
? null
: unsubscribeTopicId;
const whereKey = {
emailAddress: normalizedEmailAddress,
unsubscribeTopicId: isDefined(effectiveTopicId)
? effectiveTopicId
: IsNull(),
};
const escalateExisting = async (): Promise<boolean> => {
const existing = await this.suppressionRepository.findOneBy(
workspaceId,
whereKey,
);
if (!isDefined(existing)) {
return false;
}
if (this.shouldEscalate(existing.reason, reason)) {
await this.suppressionRepository.update(
workspaceId,
{ id: existing.id },
{ reason, source, providerEventId },
);
}
return true;
};
if (await escalateExisting()) {
return;
}
try {
await this.suppressionRepository.insert(workspaceId, {
emailAddress: normalizedEmailAddress,
reason,
source,
providerEventId,
unsubscribeTopicId: effectiveTopicId,
});
} catch (error) {
const isUniqueViolation =
error instanceof QueryFailedError &&
(error as QueryFailedErrorWithCode).code ===
POSTGRESQL_ERROR_CODES.UNIQUE_VIOLATION;
if (!isUniqueViolation || !(await escalateExisting())) {
throw error;
}
}
}
async getTopicOptOutState({
workspaceId,
emailAddress,
}: TopicOptOutStateArgs): Promise<TopicOptOutState[]> {
const normalizedEmailAddress = this.normalizeEmailAddress(emailAddress);
if (!isNonEmptyString(normalizedEmailAddress)) {
return [];
}
const visibleTopics =
await this.unsubscribeTopicService.findPublicTopics(workspaceId);
if (!isNonEmptyArray(visibleTopics)) {
return [];
}
const optOuts = await this.suppressionRepository.find(workspaceId, {
where: {
emailAddress: normalizedEmailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
unsubscribeTopicId: In(visibleTopics.map((topic) => topic.id)),
},
});
const optedOutTopicIds = new Set(
optOuts.map((suppression) => suppression.unsubscribeTopicId),
);
return visibleTopics.map((topic) => ({
unsubscribeTopicId: topic.id,
topicName: topic.name,
optedOut: optedOutTopicIds.has(topic.id),
}));
}
async setTopicOptOuts({
workspaceId,
emailAddress,
keptTopicIds,
}: SetTopicOptOutsArgs): Promise<void> {
const topicStates = await this.getTopicOptOutState({
workspaceId,
emailAddress,
});
const keptTopicIdSet = new Set(keptTopicIds);
for (const topicState of topicStates) {
const shouldReceive = keptTopicIdSet.has(topicState.unsubscribeTopicId);
if (shouldReceive === !topicState.optedOut) {
continue;
}
if (shouldReceive) {
await this.liftTopicOptOut(
workspaceId,
emailAddress,
topicState.unsubscribeTopicId,
);
} else {
await this.suppress({
workspaceId,
emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
unsubscribeTopicId: topicState.unsubscribeTopicId,
});
}
}
}
private async liftTopicOptOut(
workspaceId: string,
emailAddress: string,
unsubscribeTopicId: string,
): Promise<void> {
const normalizedEmailAddress = this.normalizeEmailAddress(emailAddress);
await this.suppressionRepository.delete(workspaceId, {
emailAddress: normalizedEmailAddress,
unsubscribeTopicId,
reason: MessageSuppressionReason.UNSUBSCRIBE,
});
}
private normalizeEmailAddress(emailAddress: string): string {
return emailAddress.trim().toLowerCase();
}
private normalizeAddresses(emailAddresses: string[]): string[] {
return [
...new Set(
emailAddresses.map((emailAddress) =>
this.normalizeEmailAddress(emailAddress),
),
),
];
}
private shouldEscalate(
existingReason: MessageSuppressionReason,
incomingReason: MessageSuppressionReason,
): boolean {
return (
!HARD_SUPPRESSION_REASONS.includes(existingReason) &&
HARD_SUPPRESSION_REASONS.includes(incomingReason)
);
}
}
@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { UnsubscribeTopicEntity } from 'src/engine/core-modules/emailing-domain/unsubscribe-topic.entity';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
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';
type CreateUnsubscribeTopicArgs = {
name: string;
description?: string | null;
visibility?: UnsubscribeTopicVisibility | null;
};
type UpdateUnsubscribeTopicArgs = {
id: string;
name?: string | null;
description?: string | null;
visibility?: UnsubscribeTopicVisibility | null;
};
@Injectable()
export class UnsubscribeTopicService {
constructor(
@InjectWorkspaceScopedRepository(UnsubscribeTopicEntity)
private readonly unsubscribeTopicRepository: WorkspaceScopedRepository<UnsubscribeTopicEntity>,
) {}
async getUnsubscribeTopics(
workspaceId: string,
): Promise<UnsubscribeTopicEntity[]> {
return this.unsubscribeTopicRepository.find(workspaceId, {
order: { name: 'ASC' },
});
}
async findPublicTopics(
workspaceId: string,
): Promise<UnsubscribeTopicEntity[]> {
return this.unsubscribeTopicRepository.find(workspaceId, {
where: { visibility: UnsubscribeTopicVisibility.PUBLIC },
order: { name: 'ASC' },
});
}
async createUnsubscribeTopic(
workspaceId: string,
{ name, description = null, visibility }: CreateUnsubscribeTopicArgs,
): Promise<UnsubscribeTopicEntity> {
return this.unsubscribeTopicRepository.save(workspaceId, {
name,
description,
visibility: visibility ?? UnsubscribeTopicVisibility.PRIVATE,
});
}
async updateUnsubscribeTopic(
workspaceId: string,
{ id, name, description, visibility }: UpdateUnsubscribeTopicArgs,
): Promise<UnsubscribeTopicEntity> {
const existing = await this.unsubscribeTopicRepository.findOneOrFail(
workspaceId,
{ where: { id } },
);
return this.unsubscribeTopicRepository.save(workspaceId, {
...existing,
...(name !== undefined ? { name } : {}),
...(description !== undefined ? { description } : {}),
...(visibility !== undefined && visibility !== null
? { visibility }
: {}),
});
}
async deleteUnsubscribeTopic(workspaceId: string, id: string): Promise<void> {
await this.unsubscribeTopicRepository.delete(workspaceId, { id });
}
}
@@ -0,0 +1,28 @@
import { type EmailsMetadata, FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageListWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const SUBJECT_FIELD_NAME = 'subject';
export const SEARCH_FIELDS_FOR_MESSAGE_CAMPAIGN: FieldTypeAndNameMetadata[] = [
{ name: SUBJECT_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageCampaignWorkspaceEntity extends BaseWorkspaceEntity {
subject: string | null;
bodyTemplate: string | null;
fromAddress: EmailsMetadata | null;
status: string;
sentAt: Date | null;
unsubscribeTopicId: string | null;
list: EntityRelation<MessageListWorkspaceEntity> | null;
listId: string | null;
messages: EntityRelation<MessageWorkspaceEntity[]>;
recipients: EntityRelation<MessageParticipantWorkspaceEntity[]>;
searchVector: string;
}
@@ -0,0 +1,12 @@
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageListWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list.workspace-entity';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export class MessageListMemberWorkspaceEntity extends BaseWorkspaceEntity {
list: EntityRelation<MessageListWorkspaceEntity>;
listId: string;
person: EntityRelation<PersonWorkspaceEntity>;
personId: string;
searchVector: string;
}
@@ -0,0 +1,20 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type MessageListMemberWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list-member.workspace-entity';
const NAME_FIELD_NAME = 'name';
export const SEARCH_FIELDS_FOR_MESSAGE_LIST: FieldTypeAndNameMetadata[] = [
{ name: NAME_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageListWorkspaceEntity extends BaseWorkspaceEntity {
name: string | null;
members: EntityRelation<MessageListMemberWorkspaceEntity[]>;
campaigns: EntityRelation<MessageCampaignWorkspaceEntity[]>;
searchVector: string;
}
@@ -0,0 +1,14 @@
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
const CAMPAIGN_VARIABLE_PATTERN = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
export const renderCampaignTemplate = (
template: string,
variables: Record<string, string>,
{ escapeValues }: { escapeValues: boolean },
): string =>
template.replace(CAMPAIGN_VARIABLE_PATTERN, (_match, variableName) => {
const value = variables[variableName] ?? '';
return escapeValues ? escapeHtml(value) : value;
});
@@ -0,0 +1,29 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
import { getMessagingWebhookExceptionStatusCode } from 'src/modules/messaging-webhooks/utils/get-messaging-webhook-exception-status-code.util';
@Catch(MessagingWebhookException)
export class MessagingWebhookApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: MessagingWebhookException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
return this.httpExceptionHandlerService.handleError(
exception,
response,
getMessagingWebhookExceptionStatusCode(exception),
);
}
}
@@ -0,0 +1,9 @@
export enum MessagingWebhookExceptionCode {
MESSAGING_WEBHOOK_MISSING_REQUEST_BODY = 'MESSAGING_WEBHOOK_MISSING_REQUEST_BODY',
MESSAGING_WEBHOOK_INVALID_PAYLOAD = 'MESSAGING_WEBHOOK_INVALID_PAYLOAD',
MESSAGING_WEBHOOK_FORBIDDEN_TOPIC = 'MESSAGING_WEBHOOK_FORBIDDEN_TOPIC',
MESSAGING_WEBHOOK_INVALID_SIGNATURE = 'MESSAGING_WEBHOOK_INVALID_SIGNATURE',
MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL = 'MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL',
MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED = 'MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED',
MESSAGING_WEBHOOK_UNHANDLED_ERROR = 'MESSAGING_WEBHOOK_UNHANDLED_ERROR',
}
@@ -0,0 +1,39 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getMessagingWebhookExceptionUserFriendlyMessage = (
code: MessagingWebhookExceptionCode,
) => {
switch (code) {
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL:
return msg`The webhook request could not be processed.`;
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_FORBIDDEN_TOPIC:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SIGNATURE:
return msg`The webhook request could not be authenticated.`;
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_UNHANDLED_ERROR:
return msg`An error occurred while processing the webhook.`;
default:
assertUnreachable(code);
}
};
export class MessagingWebhookException extends CustomException<MessagingWebhookExceptionCode> {
constructor(
message: string,
code: MessagingWebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getMessagingWebhookExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,61 @@
import {
Controller,
HttpCode,
Post,
type RawBodyRequest,
Req,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { type Request } from 'express';
import { MessagingWebhookApiExceptionFilter } from 'src/modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
import { SesInboundWebhookRouterService } from 'src/modules/messaging-webhooks/services/ses-inbound-webhook-router.service';
import { SesOutboundWebhookRouterService } from 'src/modules/messaging-webhooks/services/ses-outbound-webhook-router.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { isDefined } from 'twenty-shared/utils';
@Controller()
@UseFilters(MessagingWebhookApiExceptionFilter)
export class MessagingWebhooksController {
constructor(
private readonly sesInboundWebhookRouterService: SesInboundWebhookRouterService,
private readonly sesOutboundWebhookRouterService: SesOutboundWebhookRouterService,
) {}
@Post(['webhooks/messaging/ses/inbound'])
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@HttpCode(200)
async handleSesInboundWebhook(
@Req() request: RawBodyRequest<Request>,
): Promise<void> {
if (!isDefined(request.rawBody)) {
throw new MessagingWebhookException(
'Missing SNS payload',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY,
);
}
await this.sesInboundWebhookRouterService.route(request.rawBody);
}
@Post(['webhooks/messaging/ses/outbound'])
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@HttpCode(200)
async handleSesOutboundWebhook(
@Req() request: RawBodyRequest<Request>,
): Promise<void> {
if (!isDefined(request.rawBody)) {
throw new MessagingWebhookException(
'Missing SNS payload',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY,
);
}
await this.sesOutboundWebhookRouterService.route(request.rawBody);
}
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { MessagingWebhooksController } from 'src/modules/messaging-webhooks/messaging-webhooks.controller';
import { SesInboundMailHandlerService } from 'src/modules/messaging-webhooks/services/ses-inbound-mail-handler.service';
import { SesInboundUnsubscribeHandlerService } from 'src/modules/messaging-webhooks/services/ses-inbound-unsubscribe-handler.service';
import { SesInboundWebhookRouterService } from 'src/modules/messaging-webhooks/services/ses-inbound-webhook-router.service';
import { SesOutboundSendingStateHandlerService } from 'src/modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { SesOutboundSuppressionHandlerService } from 'src/modules/messaging-webhooks/services/ses-outbound-suppression-handler.service';
import { SesOutboundWebhookRouterService } from 'src/modules/messaging-webhooks/services/ses-outbound-webhook-router.service';
import { SnsSignatureVerifierService } from 'src/modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Module({
imports: [TwentyConfigModule, EmailingDomainModule, EmailingModule],
controllers: [MessagingWebhooksController],
providers: [
SnsSignatureVerifierService,
SnsSubscriptionConfirmerService,
SesInboundMailHandlerService,
SesInboundUnsubscribeHandlerService,
SesOutboundSendingStateHandlerService,
SesOutboundSuppressionHandlerService,
SesInboundWebhookRouterService,
SesOutboundWebhookRouterService,
],
})
export class MessagingWebhooksModule {}
@@ -0,0 +1,84 @@
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import { type EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
import { SesOutboundSendingStateHandlerService } from 'src/modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { type SesEventBridgeNotification } from 'src/modules/messaging-webhooks/types/ses-event-bridge-notification.type';
describe('SesOutboundSendingStateHandlerService.handle', () => {
const setUp = () => {
const emailingDomainTenantStatusService = {
setTenantStatusForWorkspace: jest.fn().mockResolvedValue(undefined),
} as unknown as EmailingDomainTenantStatusService;
const service = new SesOutboundSendingStateHandlerService(
emailingDomainTenantStatusService,
);
return { service, emailingDomainTenantStatusService };
};
// SES emits `Sending Status Enabled|Disabled` against three resource scopes
// (tenant / configuration-set / identity). The handler must mirror the
// status onto the workspace's DB column regardless of which scope produced
// the event, since every twenty-managed resource shares the same prefix.
describe.each([
{
scope: 'tenant ARN with opaque tenant-id segment',
arn: 'arn:aws:ses:us-east-1:123456789012:tenant/twenty-workspace-ws1/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
},
{
scope: 'configuration-set ARN',
arn: 'arn:aws:ses:us-east-1:123456789012:configuration-set/twenty-workspace-ws1',
},
{
scope: 'identity ARN',
arn: 'arn:aws:ses:us-east-1:123456789012:identity/twenty-workspace-ws1',
},
])('on a $scope', ({ arn }) => {
it.each([
{
detailType: 'Sending Status Disabled' as const,
expected: EmailingDomainTenantStatus.PAUSED,
},
{
detailType: 'Sending Status Enabled' as const,
expected: EmailingDomainTenantStatus.ACTIVE,
},
])(
'mirrors "$detailType" to tenantStatus=$expected',
async ({ detailType, expected }) => {
const { service, emailingDomainTenantStatusService } = setUp();
const event: SesEventBridgeNotification = {
source: 'aws.ses',
'detail-type': detailType,
resources: [arn],
};
await service.handle(event);
expect(
emailingDomainTenantStatusService.setTenantStatusForWorkspace,
).toHaveBeenCalledWith('ws1', expected);
},
);
});
// Security-critical: a foreign SES resource that lands on the shared SNS
// topic must not be allowed to flip our tenantStatus column. The
// workspaceId resolver returns null for any ARN that doesn't carry the
// twenty-managed name prefix; the handler must noop in that case.
it('does not update any workspace when the ARN does not carry the twenty-managed prefix', async () => {
const { service, emailingDomainTenantStatusService } = setUp();
await service.handle({
source: 'aws.ses',
'detail-type': 'Sending Status Disabled',
resources: [
'arn:aws:ses:us-east-1:123456789012:tenant/some-other-prefix/abc',
],
});
expect(
emailingDomainTenantStatusService.setTenantStatusForWorkspace,
).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,63 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { SesInboundUnsubscribeHandlerService } from 'src/modules/messaging-webhooks/services/ses-inbound-unsubscribe-handler.service';
import { type SesInboundNotification } from 'src/modules/messaging-webhooks/types/sns-message.type';
import { resolveInboundMailIntent } from 'src/modules/messaging-webhooks/utils/resolve-inbound-mail-intent.util';
import {
MessagingInboundEmailImportJob,
type MessagingInboundEmailImportJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job';
@Injectable()
export class SesInboundMailHandlerService {
private readonly logger = new Logger(SesInboundMailHandlerService.name);
constructor(
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly sesInboundUnsubscribeHandlerService: SesInboundUnsubscribeHandlerService,
) {}
async handle(
notification: SesInboundNotification,
snsMessageId: string,
): Promise<void> {
switch (resolveInboundMailIntent(notification)) {
case 'UNSUBSCRIBE':
await this.sesInboundUnsubscribeHandlerService.handle(notification);
return;
case 'IMPORT':
await this.enqueueInboundEmailImport(notification, snsMessageId);
return;
}
}
private async enqueueInboundEmailImport(
notification: SesInboundNotification,
snsMessageId: string,
): Promise<void> {
const { receipt } = notification;
if (receipt?.action?.type !== 'S3') {
this.logger.warn(
`SNS message ${snsMessageId} has unsupported action type ${receipt?.action?.type}`,
);
return;
}
await this.messageQueueService.add<MessagingInboundEmailImportJobData>(
MessagingInboundEmailImportJob.name,
{
s3Key: receipt.action.objectKey,
envelopeRecipients: receipt.recipients,
},
{ id: snsMessageId },
);
}
}
@@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
import { type SesInboundNotification } from 'src/modules/messaging-webhooks/types/sns-message.type';
@Injectable()
export class SesInboundUnsubscribeHandlerService {
private readonly logger = new Logger(
SesInboundUnsubscribeHandlerService.name,
);
constructor(
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly messageSuppressionService: MessageSuppressionService,
) {}
async handle(notification: SesInboundNotification): Promise<void> {
const subject = notification.mail?.commonHeaders?.subject;
if (!isNonEmptyString(subject)) {
this.logger.warn('Unsubscribe email received without a token subject');
return;
}
const payload = this.unsubscribeTokenService.verify(subject.trim());
if (!isDefined(payload)) {
this.logger.warn('Unsubscribe email received with an invalid token');
return;
}
if (payload.preview === true) {
return;
}
await this.messageSuppressionService.suppress({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
unsubscribeTopicId: payload.unsubscribeTopicId ?? null,
});
}
}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import type SnsPayloadValidator from 'sns-payload-validator';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
import { SesInboundMailHandlerService } from 'src/modules/messaging-webhooks/services/ses-inbound-mail-handler.service';
import { SnsSignatureVerifierService } from 'src/modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { type SesInboundNotification } from 'src/modules/messaging-webhooks/types/sns-message.type';
type SnsPayload = SnsPayloadValidator.SnsPayload;
@Injectable()
export class SesInboundWebhookRouterService {
constructor(
private readonly snsSignatureVerifierService: SnsSignatureVerifierService,
private readonly snsSubscriptionConfirmerService: SnsSubscriptionConfirmerService,
private readonly sesInboundMailHandlerService: SesInboundMailHandlerService,
) {}
async route(rawBody: Buffer): Promise<void> {
const payload = parseJson<SnsPayload>(rawBody.toString('utf8'));
if (!isDefined(payload)) {
throw new MessagingWebhookException(
'Invalid SNS payload',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
);
}
await this.snsSignatureVerifierService.assertAllowedAndSigned(payload);
if (
payload.Type === 'SubscriptionConfirmation' ||
payload.Type === 'UnsubscribeConfirmation'
) {
await this.snsSubscriptionConfirmerService.confirm(payload.SubscribeURL);
return;
}
if (payload.Type !== 'Notification') {
return;
}
const notification = parseJson<SesInboundNotification>(payload.Message);
if (!isDefined(notification)) {
throw new MessagingWebhookException(
'Invalid SNS notification message',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
);
}
await this.sesInboundMailHandlerService.handle(
notification,
payload.MessageId,
);
}
}
@@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import { EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
import { type SesEventBridgeNotification } from 'src/modules/messaging-webhooks/types/ses-event-bridge-notification.type';
import { resolveWorkspaceIdFromAwsSesResources } from 'src/modules/messaging-webhooks/utils/resolve-workspace-id-from-aws-ses-resources.util';
import { isDefined } from 'twenty-shared/utils';
@Injectable()
export class SesOutboundSendingStateHandlerService {
private readonly logger = new Logger(
SesOutboundSendingStateHandlerService.name,
);
constructor(
private readonly emailingDomainTenantStatusService: EmailingDomainTenantStatusService,
) {}
async handle(event: SesEventBridgeNotification): Promise<void> {
const targetStatus =
event['detail-type'] === 'Sending Status Enabled'
? EmailingDomainTenantStatus.ACTIVE
: EmailingDomainTenantStatus.PAUSED;
const workspaceId = resolveWorkspaceIdFromAwsSesResources(event.resources);
if (!isDefined(workspaceId)) {
this.logger.warn(
`Could not resolve workspaceId from SES sending-state event resources: ${JSON.stringify(event.resources)}`,
);
return;
}
await this.emailingDomainTenantStatusService.setTenantStatusForWorkspace(
workspaceId,
targetStatus,
);
}
}
@@ -0,0 +1,129 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { CAMPAIGN_MESSAGE_DELIVERY_STATUS } from 'src/engine/core-modules/emailing-domain/constants/campaign.constant';
import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
import { type SesEventBridgeNotification } from 'src/modules/messaging-webhooks/types/ses-event-bridge-notification.type';
import { resolveWorkspaceIdFromAwsSesResources } from 'src/modules/messaging-webhooks/utils/resolve-workspace-id-from-aws-ses-resources.util';
@Injectable()
export class SesOutboundSuppressionHandlerService {
private readonly logger = new Logger(
SesOutboundSuppressionHandlerService.name,
);
constructor(
private readonly messageSuppressionService: MessageSuppressionService,
private readonly messageCampaignService: MessageCampaignService,
) {}
async handle(event: SesEventBridgeNotification): Promise<void> {
const suppression = this.resolveSuppression(event);
if (!isDefined(suppression)) {
return;
}
const workspaceId = resolveWorkspaceIdFromAwsSesResources(event.resources);
if (!isDefined(workspaceId)) {
this.logger.warn(
`Could not resolve workspaceId from SES ${event['detail-type']} event resources: ${JSON.stringify(event.resources)}`,
);
return;
}
const providerMessageId = event.detail?.mail?.messageId;
if (isDefined(providerMessageId)) {
await this.messageCampaignService.recordDeliveryFailureByProviderMessageId(
{
workspaceId,
providerMessageId,
deliveryStatus:
event['detail-type'] === 'Email Complaint Received'
? CAMPAIGN_MESSAGE_DELIVERY_STATUS.COMPLAINED
: CAMPAIGN_MESSAGE_DELIVERY_STATUS.BOUNCED,
},
);
}
const results = await Promise.allSettled(
suppression.emailAddresses.map((emailAddress) =>
this.messageSuppressionService.suppress({
workspaceId,
emailAddress,
reason: suppression.reason,
source: MessageSuppressionSource.WEBHOOK,
providerEventId: suppression.providerEventId,
}),
),
);
if (results.some((result) => result.status === 'rejected')) {
throw new Error(
`Failed to suppress one or more recipients for ${event['detail-type']} event in workspace ${workspaceId}`,
);
}
}
private resolveSuppression(event: SesEventBridgeNotification): {
reason: MessageSuppressionReason;
emailAddresses: string[];
providerEventId: string | null;
} | null {
if (event['detail-type'] === 'Email Bounced') {
const bounce = event.detail?.bounce;
if (bounce?.bounceType !== 'Permanent') {
return null;
}
const emailAddresses = this.extractRecipientAddresses(
bounce.bouncedRecipients,
);
if (!isNonEmptyArray(emailAddresses)) {
return null;
}
return {
reason: MessageSuppressionReason.BOUNCE,
emailAddresses,
providerEventId: bounce.feedbackId ?? null,
};
}
if (event['detail-type'] === 'Email Complaint Received') {
const complaint = event.detail?.complaint;
const emailAddresses = this.extractRecipientAddresses(
complaint?.complainedRecipients,
);
if (!isNonEmptyArray(emailAddresses)) {
return null;
}
return {
reason: MessageSuppressionReason.COMPLAINT,
emailAddresses,
providerEventId: complaint?.feedbackId ?? null,
};
}
return null;
}
private extractRecipientAddresses = (
recipients: { emailAddress: string }[] | undefined,
): string[] => {
return (recipients ?? [])
.map((recipient) => recipient.emailAddress)
.filter(isDefined);
};
}
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import type SnsPayloadValidator from 'sns-payload-validator';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
import { SesOutboundSendingStateHandlerService } from 'src/modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { SesOutboundSuppressionHandlerService } from 'src/modules/messaging-webhooks/services/ses-outbound-suppression-handler.service';
import { SnsSignatureVerifierService } from 'src/modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { type SesEventBridgeNotification } from 'src/modules/messaging-webhooks/types/ses-event-bridge-notification.type';
type SnsPayload = SnsPayloadValidator.SnsPayload;
@Injectable()
export class SesOutboundWebhookRouterService {
constructor(
private readonly snsSignatureVerifierService: SnsSignatureVerifierService,
private readonly snsSubscriptionConfirmerService: SnsSubscriptionConfirmerService,
private readonly sesOutboundSendingStateHandlerService: SesOutboundSendingStateHandlerService,
private readonly sesOutboundSuppressionHandlerService: SesOutboundSuppressionHandlerService,
) {}
async route(rawBody: Buffer): Promise<void> {
const payload = parseJson<SnsPayload>(rawBody.toString('utf8'));
if (!isDefined(payload)) {
throw new MessagingWebhookException(
'Invalid SNS payload',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
);
}
await this.snsSignatureVerifierService.assertAllowedAndSigned(payload);
if (
payload.Type === 'SubscriptionConfirmation' ||
payload.Type === 'UnsubscribeConfirmation'
) {
await this.snsSubscriptionConfirmerService.confirm(payload.SubscribeURL);
return;
}
if (payload.Type !== 'Notification') {
return;
}
const event = parseJson<SesEventBridgeNotification>(payload.Message);
if (!isDefined(event)) {
throw new MessagingWebhookException(
'Invalid SNS notification message',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
);
}
if (
event['detail-type'] === 'Email Bounced' ||
event['detail-type'] === 'Email Complaint Received'
) {
await this.sesOutboundSuppressionHandlerService.handle(event);
return;
}
await this.sesOutboundSendingStateHandlerService.handle(event);
}
}
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import SnsPayloadValidator from 'sns-payload-validator';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
type SnsPayload = SnsPayloadValidator.SnsPayload;
@Injectable()
export class SnsSignatureVerifierService {
private readonly logger = new Logger(SnsSignatureVerifierService.name);
private readonly validator = new SnsPayloadValidator();
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async assertAllowedAndSigned(payload: SnsPayload): Promise<void> {
if (!this.isTopicAllowlisted(payload.TopicArn)) {
this.logger.warn(`SNS topic ${payload.TopicArn} is not in allowlist`);
throw new MessagingWebhookException(
'SNS topic not allowed',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_FORBIDDEN_TOPIC,
);
}
try {
await this.validator.validate(payload);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.warn(`SNS signature verification failed: ${errorMessage}`);
throw new MessagingWebhookException(
'SNS signature invalid',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SIGNATURE,
);
}
}
private isTopicAllowlisted(topicArn: string): boolean {
const allowlist = this.twentyConfigService.get(
'SES_SNS_TOPIC_ARN_ALLOWLIST',
);
if (typeof allowlist !== 'string' || allowlist.trim() === '') {
return false;
}
return allowlist
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.includes(topicArn);
}
}
@@ -0,0 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
const SNS_SUBSCRIBE_URL_PATTERN =
/^https:\/\/sns\.[a-z0-9-]+\.amazonaws\.com\//;
@Injectable()
export class SnsSubscriptionConfirmerService {
private readonly logger = new Logger(SnsSubscriptionConfirmerService.name);
async confirm(subscribeUrl: string | undefined): Promise<void> {
if (!subscribeUrl) {
throw new MessagingWebhookException(
'Missing SubscribeURL on SNS subscription confirmation',
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
);
}
if (!SNS_SUBSCRIBE_URL_PATTERN.test(subscribeUrl)) {
throw new MessagingWebhookException(
`Refusing to fetch non-AWS SubscribeURL: ${subscribeUrl}`,
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL,
);
}
const response = await fetch(subscribeUrl);
if (!response.ok) {
throw new MessagingWebhookException(
`Failed to confirm SNS subscription via ${subscribeUrl}: ${response.status}`,
MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED,
);
}
this.logger.log(`Confirmed SNS subscription via ${subscribeUrl}`);
}
}
@@ -0,0 +1,37 @@
type SesEventBridgeDetailType =
| 'Sending Status Enabled'
| 'Sending Status Disabled'
| 'Email Bounced'
| 'Email Complaint Received';
type SesEventBridgeRecipient = {
emailAddress: string;
};
export type SesEventBridgeNotification = {
source: 'aws.ses';
'detail-type': SesEventBridgeDetailType;
resources?: string[];
detail?: {
version?: string;
data?: {
origin?: string;
record?: {
status?: 'ENABLED' | 'DISABLED';
cause?: string;
};
};
bounce?: {
bounceType?: 'Permanent' | 'Transient' | 'Undetermined';
feedbackId?: string;
bouncedRecipients?: SesEventBridgeRecipient[];
};
complaint?: {
feedbackId?: string;
complainedRecipients?: SesEventBridgeRecipient[];
};
mail?: {
messageId?: string;
};
};
};
@@ -0,0 +1 @@
export type SesInboundMailIntent = 'UNSUBSCRIBE' | 'IMPORT';
@@ -0,0 +1,5 @@
import { type SESMessage } from 'aws-lambda';
export type SesInboundNotification = SESMessage & {
notificationType?: string;
};
@@ -0,0 +1,53 @@
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util';
describe('parseWorkspaceIdFromAwsSesResourceArn', () => {
// Tenant ARNs have an AWS-assigned opaque id segment after the tenant name
// that must be discarded; configuration-set and identity ARNs do not. A
// single helper has to handle both shapes consistently.
it.each([
{
label: 'tenant ARN (drops the AWS-assigned tenant-id segment)',
arn: 'arn:aws:ses:us-east-1:123456789012:tenant/twenty-workspace-ws1/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
},
{
label: 'configuration-set ARN (single segment after resource type)',
arn: 'arn:aws:ses:us-east-1:123456789012:configuration-set/twenty-workspace-ws1',
},
{
label: 'identity ARN (single segment after resource type)',
arn: 'arn:aws:ses:us-east-1:123456789012:identity/twenty-workspace-ws1',
},
])('extracts the workspaceId from a $label', ({ arn }) => {
expect(parseWorkspaceIdFromAwsSesResourceArn(arn)).toBe('ws1');
});
it('preserves the workspaceId verbatim when it is a UUID', () => {
expect(
parseWorkspaceIdFromAwsSesResourceArn(
'arn:aws:ses:us-east-1:123456789012:tenant/twenty-workspace-20202020-cb1b-4e35-b50f-2bbd09c3b1ee/9b1deb4d',
),
).toBe('20202020-cb1b-4e35-b50f-2bbd09c3b1ee');
});
// The prefix-check is the only guard preventing cross-tenant updates from
// foreign SES resources hitting the same SNS topic; an empty workspaceId
// (resource named exactly "twenty-workspace-") would otherwise produce a
// catastrophic empty WHERE clause downstream.
it.each([
{
label: 'foreign prefix',
arn: 'arn:aws:ses:us-east-1:123456789012:tenant/some-other-prefix/abc',
},
{
label: 'empty workspaceId after the prefix',
arn: 'arn:aws:ses:us-east-1:123456789012:tenant/twenty-workspace-/abc',
},
{
label: 'malformed ARN with no resource segment',
arn: 'arn:aws:ses:us-east-1:123456789012:tenant',
},
{ label: 'empty string', arn: '' },
])('returns null for $label', ({ arn }) => {
expect(parseWorkspaceIdFromAwsSesResourceArn(arn)).toBeNull();
});
});
@@ -0,0 +1,24 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { type MessagingWebhookException } from 'src/modules/messaging-webhooks/messaging-webhook.exception';
export const getMessagingWebhookExceptionStatusCode = (
exception: MessagingWebhookException,
): 400 | 403 | 500 => {
switch (exception.code) {
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL:
return 400;
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_FORBIDDEN_TOPIC:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SIGNATURE:
return 403;
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED:
case MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_UNHANDLED_ERROR:
return 500;
default: {
return assertUnreachable(exception.code);
}
}
};
@@ -0,0 +1,29 @@
import { AWS_SES_RESOURCE_NAME_PREFIX } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant';
import { isDefined } from 'twenty-shared/utils';
export const parseWorkspaceIdFromAwsSesResourceArn = (
resourceArn: string,
): string | null => {
const slashIndex = resourceArn.indexOf('/');
if (slashIndex === -1) {
return null;
}
const afterPrefix = resourceArn.slice(slashIndex + 1);
const resourceName = afterPrefix.split('/')[0];
if (!isDefined(resourceName)) {
return null;
}
const expectedPrefix = `${AWS_SES_RESOURCE_NAME_PREFIX}-`;
if (!resourceName.startsWith(expectedPrefix)) {
return null;
}
const workspaceId = resourceName.slice(expectedPrefix.length);
return workspaceId.length > 0 ? workspaceId : null;
};
@@ -0,0 +1,16 @@
import { UNSUBSCRIBE_MAILBOX_LOCAL_PART } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-mailbox.constant';
import { type SesInboundMailIntent } from 'src/modules/messaging-webhooks/types/ses-inbound-mail-intent.type';
import { type SesInboundNotification } from 'src/modules/messaging-webhooks/types/sns-message.type';
export const resolveInboundMailIntent = (
notification: SesInboundNotification,
): SesInboundMailIntent => {
const isAddressedToUnsubscribeMailbox = (
notification.receipt?.recipients ?? []
).some(
(recipient) =>
recipient.split('@')[0]?.toLowerCase() === UNSUBSCRIBE_MAILBOX_LOCAL_PART,
);
return isAddressedToUnsubscribeMailbox ? 'UNSUBSCRIBE' : 'IMPORT';
};
@@ -0,0 +1,21 @@
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util';
export const resolveWorkspaceIdFromAwsSesResources = (
resources: string[] | undefined,
): string | null => {
if (!isNonEmptyArray(resources)) {
return null;
}
for (const resourceArn of resources) {
const workspaceId = parseWorkspaceIdFromAwsSesResourceArn(resourceArn);
if (isDefined(workspaceId)) {
return workspaceId;
}
}
return null;
};
@@ -26,6 +26,9 @@ const createMockMessage = (
messageThread: null,
messageChannelMessageAssociations: [],
messageParticipants: [],
messageCampaign: null,
messageCampaignId: null,
deliveryStatus: null,
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
@@ -7,7 +7,6 @@ import { MessageChannelVisibility } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
@@ -90,7 +89,8 @@ export class ApplyMessagesVisibilityRestrictionsService {
.filter(isDefined);
if (messageChannels.length === 0) {
throw new NotFoundError('Associated message channels not found');
messages.splice(i, 1);
continue;
}
const messageChannelsGroupByVisibility = groupBy(
@@ -4,6 +4,7 @@ import {
} from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
@@ -25,4 +26,6 @@ export class MessageParticipantWorkspaceEntity extends BaseWorkspaceEntity {
personId: string | null;
workspaceMember: EntityRelation<WorkspaceMemberWorkspaceEntity> | null;
workspaceMemberId: string | null;
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
}
@@ -3,6 +3,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
@@ -24,4 +25,7 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageChannelMessageAssociations: EntityRelation<
MessageChannelMessageAssociationWorkspaceEntity[]
>;
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
deliveryStatus: string | null;
}
@@ -54,7 +54,6 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@@ -86,7 +85,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMessageCleanerModule,
WorkspaceEventEmitterModule,
ConnectedAccountModule,
MessagingWebhooksModule,
],
providers: [
provideWorkspaceScopedRepository(MessageChannelEntity),
@@ -13,6 +13,9 @@ export type Message = Omit<
| 'messageThreadId'
| 'messageFolders'
| 'id'
| 'messageCampaign'
| 'messageCampaignId'
| 'deliveryStatus'
> & {
attachments: {
filename: string;
@@ -43,6 +46,8 @@ export type MessageParticipant = Omit<
| 'workspaceMember'
| 'message'
| 'messageId'
| 'messageCampaign'
| 'messageCampaignId'
>;
export type MessageWithParticipants = Message & {
@@ -1,7 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
export const filterOutInternals = (
primaryHandle: string,
@@ -12,7 +12,7 @@ export const filterOutInternals = (
return true;
}
const primaryHandleDomain = getDomainNameByEmail(primaryHandle);
const primaryHandleDomain = getDomainFromEmailOrThrow(primaryHandle);
try {
const isAllHandlesFromSameDomain = message.participants
@@ -20,7 +20,8 @@ export const filterOutInternals = (
.every(
(participant) =>
isDefined(participant.handle) &&
getDomainNameByEmail(participant.handle) === primaryHandleDomain,
getDomainFromEmailOrThrow(participant.handle) ===
primaryHandleDomain,
);
if (isAllHandlesFromSameDomain) {
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
MessageChannelException,
@@ -16,13 +16,14 @@ import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scope
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
@Injectable()
export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainService: EmailingDomainService,
private readonly emailingDomainSenderService: EmailingDomainSenderService,
) {}
async sendMessage(
@@ -38,7 +39,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
);
}
const result = await this.emailingDomainService.sendEmail(
const result = await this.emailingDomainSenderService.sendEmail(
connectedAccount.workspaceId,
emailingDomain.id,
{
@@ -59,6 +60,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
return {
headerMessageId: result.messageId,
messageExternalId: result.messageId,
deliveredRecipients: result.deliveredRecipients,
};
}
@@ -72,7 +74,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
private async resolveEmailingDomain(
connectedAccount: ConnectedAccountEntity,
): Promise<EmailingDomainEntity> {
const handleDomain = connectedAccount.handle.split('@')[1];
const handleDomain = getDomainFromEmail(connectedAccount.handle);
if (!isNonEmptyString(handleDomain)) {
throw new MessageChannelException(
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { EmailingModule } from 'src/modules/emailing/emailing.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@@ -24,7 +24,7 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingImportManagerModule,
EmailingDomainModule,
EmailingModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
MessageFolderEntity,
@@ -42,7 +42,7 @@ export class SendEmailService {
sendResult,
subject: data.sanitizedSubject,
body: data.plainTextBody,
recipients: data.recipients,
recipients: sendResult.deliveredRecipients ?? data.recipients,
connectedAccount: data.connectedAccount,
messageChannelId: data.messageChannelId!,
inReplyTo: data.inReplyTo,
@@ -2,4 +2,5 @@ export type SendMessageResult = {
headerMessageId: string;
messageExternalId?: string;
threadExternalId?: string;
deliveredRecipients?: { to: string[]; cc: string[]; bcc: string[] };
};
@@ -14,6 +14,7 @@ import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migr
import { type AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { type CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { type MessageListMemberWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list-member.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity';
import { type OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
@@ -57,5 +58,6 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
CalendarEventParticipantWorkspaceEntity[]
>;
timelineActivities: EntityRelation<TimelineActivityWorkspaceEntity[]>;
listMemberships: EntityRelation<MessageListMemberWorkspaceEntity[]>;
searchVector: string;
}
@@ -6,6 +6,8 @@ import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/util
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type MessageListWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list.workspace-entity';
import { type NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { type OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@@ -48,6 +50,10 @@ export class TimelineActivityWorkspaceEntity extends BaseWorkspaceEntity {
targetWorkflowRunId: string | null;
targetDashboard: EntityRelation<DashboardWorkspaceEntity> | null;
targetDashboardId: string | null;
targetMessageList: EntityRelation<MessageListWorkspaceEntity> | null;
targetMessageListId: string | null;
targetMessageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
targetMessageCampaignId: string | null;
custom: EntityRelation<CustomWorkspaceEntity>;
targetCustom: EntityRelation<CustomWorkspaceEntity>;
}