[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:
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+277
@@ -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 });
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
});
|
||||
+29
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+61
@@ -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 {}
|
||||
+84
@@ -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();
|
||||
});
|
||||
});
|
||||
+63
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+62
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+129
@@ -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);
|
||||
};
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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}`);
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SesInboundMailIntent = 'UNSUBSCRIBE' | 'IMPORT';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type SESMessage } from 'aws-lambda';
|
||||
|
||||
export type SesInboundNotification = SESMessage & {
|
||||
notificationType?: string;
|
||||
};
|
||||
+53
@@ -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();
|
||||
});
|
||||
});
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
+29
@@ -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;
|
||||
};
|
||||
+16
@@ -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';
|
||||
};
|
||||
+21
@@ -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;
|
||||
};
|
||||
+3
@@ -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',
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
-2
@@ -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 & {
|
||||
|
||||
+4
-3
@@ -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) {
|
||||
|
||||
+6
-4
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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 +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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user