[WIP] Feat/marketing emails (#21173)

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

## Model

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

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

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

## Sending

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

## Unsubscribe

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

## Architecture

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

## Frontend

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

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -27,6 +27,7 @@ import { isAsymmetricJwtHeader } from 'src/engine/core-modules/jwt/utils/is-asym
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
import { isWorkDomain } from 'src/utils/is-work-email';
const APPROVED_ACCESS_DOMAIN_TOKEN_EXPIRES_IN = '7d';
@@ -65,7 +66,7 @@ export class ApprovedAccessDomainService {
);
}
if (to.split('@')[1] !== approvedAccessDomain.domain) {
if (getDomainFromEmail(to) !== approvedAccessDomain.domain) {
throw new ApprovedAccessDomainException(
'Approved access domain does not match email domain',
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL,
@@ -71,6 +71,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
// import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
@Injectable()
@@ -896,7 +897,8 @@ export class AuthService {
if (
workspace?.approvedAccessDomains.some(
(trustDomain) =>
trustDomain.isValidated && trustDomain.domain === email.split('@')[1],
trustDomain.isValidated &&
trustDomain.domain === getDomainFromEmail(email),
)
) {
return;
@@ -47,7 +47,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
import { isWorkEmail } from 'src/utils/is-work-email';
@Injectable()
@@ -542,7 +542,7 @@ export class SignInUpService {
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainFromEmailOrThrow(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
@@ -94,7 +94,7 @@ describe('ClientConfigController', () => {
isGoogleCalendarEnabled: false,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isEmailingDomainInDemoMode: false,
calendarBookingPageId: undefined,
isTwoFactorAuthenticationEnabled: false,
allowRequestsToTwentyIcons: true,
@@ -310,7 +310,7 @@ export class ClientConfig {
isImapSmtpCaldavEnabled: boolean;
@Field(() => Boolean)
isEmailGroupEnabled: boolean;
isEmailingDomainInDemoMode: boolean;
@Field(() => Boolean)
allowRequestsToTwentyIcons: boolean;
@@ -171,7 +171,7 @@ describe('ClientConfigService', () => {
isGoogleCalendarEnabled: true,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isEmailingDomainInDemoMode: false,
allowRequestsToTwentyIcons: false,
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
@@ -13,6 +12,7 @@ import {
type ClientConfig,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
@@ -46,6 +46,10 @@ export class ClientConfigService {
'CALENDAR_BOOKING_PAGE_ID',
);
const isEmailingDomainInDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
@@ -235,10 +239,7 @@ export class ClientConfigService {
isImapSmtpCaldavEnabled: this.twentyConfigService.get(
'IS_IMAP_SMTP_CALDAV_ENABLED',
),
isEmailGroupEnabled:
this.twentyConfigService.get('STORAGE_TYPE') ===
StorageDriverType.S_3 &&
isNonEmptyString(this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN')),
isEmailingDomainInDemoMode,
allowRequestsToTwentyIcons: this.twentyConfigService.get(
'ALLOW_REQUESTS_TO_TWENTY_ICONS',
),
@@ -29,6 +29,7 @@ import { CodeInterpreterModule } from 'src/engine/core-modules/code-interpreter/
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
@@ -45,7 +46,7 @@ import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logi
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory';
import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module';
import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module';
import { MessagingWebhooksModule } from 'src/modules/messaging-webhooks/messaging-webhooks.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
@@ -108,6 +109,7 @@ import { FileModule } from './file/file.module';
WorkspaceSSOModule,
ApprovedAccessDomainModule,
EmailingDomainModule,
EmailingModule,
PublicDomainModule,
CloudflareModule,
DnsManagerModule,
@@ -1,12 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
import { isWorkEmail } from 'src/utils/is-work-email';
export const getSubdomainFromEmail = (email?: string) => {
if (!isDefined(email) || !isWorkEmail(email)) return;
const domain = getDomainNameByEmail(email);
const domain = getDomainFromEmailOrThrow(email);
return domain.split('.')[0].toLowerCase();
};
@@ -0,0 +1,24 @@
export const CAMPAIGN_MESSAGE_DELIVERY_STATUS = {
QUEUED: 'QUEUED',
SENT: 'SENT',
FAILED: 'FAILED',
BOUNCED: 'BOUNCED',
COMPLAINED: 'COMPLAINED',
SKIPPED: 'SKIPPED',
} as const;
export const CAMPAIGN_STATUS = {
DRAFT: 'DRAFT',
SCHEDULED: 'SCHEDULED',
SENDING: 'SENDING',
SENT: 'SENT',
SENT_WITH_ERRORS: 'SENT_WITH_ERRORS',
} as const;
export const MATERIALIZE_CAMPAIGN_JOB = 'MaterializeCampaignJob';
export const SEND_CAMPAIGN_EMAIL_JOB = 'SendCampaignEmailJob';
export const MAX_CAMPAIGN_RECIPIENTS = 10000;
export const CAMPAIGN_MESSAGE_ID_NAMESPACE =
'0c4b9e7a-3f2d-4b6c-9e1a-7d8f5a2c3b4e';
@@ -0,0 +1,7 @@
import { type UnsubscribeContent } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-content.type';
export const EMPTY_UNSUBSCRIBE_CONTENT: UnsubscribeContent = {
headers: [],
textFooter: '',
htmlFooter: '',
};
@@ -0,0 +1,11 @@
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
export const HARD_SUPPRESSION_REASONS = [
MessageSuppressionReason.BOUNCE,
MessageSuppressionReason.COMPLAINT,
];
export const GLOBAL_BLOCKING_SUPPRESSION_REASONS = [
...HARD_SUPPRESSION_REASONS,
MessageSuppressionReason.UNSUBSCRIBE,
];
@@ -0,0 +1 @@
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
@@ -0,0 +1 @@
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
@@ -1 +0,0 @@
export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
@@ -2,7 +2,6 @@ import {
AlreadyExistsException,
CreateConfigurationSetCommand,
CreateConfigurationSetEventDestinationCommand,
CreateContactListCommand,
CreateTenantResourceAssociationCommand,
PutEmailIdentityMailFromAttributesCommand,
} from '@aws-sdk/client-sesv2';
@@ -22,7 +21,6 @@ describe('AwsSesRegisterDomainService', () => {
const provisionInput = {
tenantName: 'twenty-workspace-ws1',
configurationSetName: 'twenty-workspace-ws1',
contactListName: 'twenty-workspace-ws1',
};
const buildAlreadyExists = () =>
@@ -56,7 +54,6 @@ describe('AwsSesRegisterDomainService', () => {
expect(commandTypes).toEqual([
CreateConfigurationSetCommand.name,
CreateConfigurationSetEventDestinationCommand.name,
CreateContactListCommand.name,
CreateTenantResourceAssociationCommand.name,
]);
});
@@ -75,7 +72,6 @@ describe('AwsSesRegisterDomainService', () => {
expect(commandTypes).toEqual([
CreateConfigurationSetCommand.name,
CreateConfigurationSetEventDestinationCommand.name,
CreateContactListCommand.name,
CreateTenantResourceAssociationCommand.name,
]);
});
@@ -21,7 +21,6 @@ describe('AwsSesSendEmailService', () => {
const baseContext = {
tenantName: 'twenty-workspace-ws1',
configurationSetName: 'twenty-workspace-ws1',
contactListName: 'twenty-workspace-ws1',
};
const setUp = () => {
@@ -42,7 +41,7 @@ describe('AwsSesSendEmailService', () => {
return { service, send, handleErrorService };
};
it('should call SendEmail with tenant, config set, and list management options', async () => {
it('should call SendEmail with tenant and config set', async () => {
const { service, send } = setUp();
send.mockResolvedValue({ MessageId: 'msg-1' });
@@ -59,11 +58,8 @@ describe('AwsSesSendEmailService', () => {
Destination: { ToAddresses: ['user@example.com'] },
ConfigurationSetName: 'twenty-workspace-ws1',
TenantName: 'twenty-workspace-ws1',
ListManagementOptions: {
ContactListName: 'twenty-workspace-ws1',
TopicName: 'marketing',
},
});
expect(command.input.ListManagementOptions).toBeUndefined();
expect(command.input.EmailTags).toEqual(
expect.arrayContaining([
{ Name: 'workspace', Value: 'ws1' },
@@ -6,7 +6,6 @@ import {
CreateTenantCommand,
CreateTenantResourceAssociationCommand,
DeleteConfigurationSetCommand,
DeleteContactListCommand,
DeleteEmailIdentityCommand,
DeleteTenantCommand,
DeleteTenantResourceAssociationCommand,
@@ -21,10 +20,8 @@ import {
type EmailingDomainResourceInput,
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
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 { AWS_SES_RESOURCE_NAME_PREFIX } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant';
import { type AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
@@ -120,7 +117,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
{
tenantName,
configurationSetName: this.buildConfigurationSetName(workspaceId),
contactListName: this.buildContactListName(workspaceId),
},
this.config,
);
@@ -136,7 +132,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return this.awsSesSendEmailService.sendEmail(input, {
tenantName: this.buildTenantName(input.workspaceId),
configurationSetName: this.buildConfigurationSetName(input.workspaceId),
contactListName: this.buildContactListName(input.workspaceId),
});
}
@@ -167,7 +162,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
const sesClient = this.awsSesClientProvider.getSESClient();
const tenantName = this.buildTenantName(workspaceId);
const configurationSetName = this.buildConfigurationSetName(workspaceId);
const contactListName = this.buildContactListName(workspaceId);
const configurationSetArn = `arn:aws:ses:${this.config.region}:${this.config.accountId}:configuration-set/${configurationSetName}`;
await sesClient
@@ -191,12 +185,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
if (!(error instanceof NotFoundException)) throw error;
});
await sesClient
.send(new DeleteContactListCommand({ ContactListName: contactListName }))
.catch((error) => {
if (!(error instanceof NotFoundException)) throw error;
});
await sesClient
.send(new DeleteTenantCommand({ TenantName: tenantName }))
.catch((error) => {
@@ -212,10 +200,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return `${AWS_SES_RESOURCE_NAME_PREFIX}-${workspaceId}`;
}
private buildContactListName(workspaceId: string): string {
return `${AWS_SES_RESOURCE_NAME_PREFIX}-${workspaceId}`;
}
private async ensureTenantExists(tenantName: string): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
@@ -4,7 +4,6 @@ import {
AlreadyExistsException,
CreateConfigurationSetCommand,
CreateConfigurationSetEventDestinationCommand,
CreateContactListCommand,
CreateTenantResourceAssociationCommand,
PutEmailIdentityMailFromAttributesCommand,
} from '@aws-sdk/client-sesv2';
@@ -12,13 +11,11 @@ import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain
import { AWS_SES_EVENT_BUS_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant';
import { AWS_SES_MAIL_FROM_SUBDOMAIN } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant';
import { AWS_SES_MARKETING_TOPIC_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
type ProvisionWorkspaceInput = {
tenantName: string;
configurationSetName: string;
contactListName: string;
};
@Injectable()
@@ -42,7 +39,7 @@ export class AwsSesRegisterDomainService {
ConfigurationSetName: input.configurationSetName,
ReputationOptions: { ReputationMetricsEnabled: true },
SendingOptions: { SendingEnabled: true },
SuppressionOptions: { SuppressedReasons: ['BOUNCE', 'COMPLAINT'] },
SuppressionOptions: { SuppressedReasons: [] },
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
}),
)
@@ -79,26 +76,6 @@ export class AwsSesRegisterDomainService {
}
});
await sesClient
.send(
new CreateContactListCommand({
ContactListName: input.contactListName,
Topics: [
{
TopicName: AWS_SES_MARKETING_TOPIC_NAME,
DisplayName: 'Marketing',
DefaultSubscriptionStatus: 'OPT_IN',
},
],
Tags: [{ Key: 'managed-by', Value: 'twenty' }],
}),
)
.catch((error) => {
if (!(error instanceof AlreadyExistsException)) {
throw error;
}
});
await sesClient
.send(
new CreateTenantResourceAssociationCommand({
@@ -3,12 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { SendEmailCommand } from '@aws-sdk/client-sesv2';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
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 { AWS_SES_MARKETING_TOPIC_NAME } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import {
@@ -19,7 +16,6 @@ import {
type SendEmailContext = {
tenantName: string;
configurationSetName: string;
contactListName: string;
};
@Injectable()
@@ -56,6 +52,12 @@ export class AwsSesSendEmailService {
ReplyToAddresses: input.replyTo,
Content: {
Simple: {
Headers: isNonEmptyArray(input.headers)
? input.headers.map((header) => ({
Name: header.name,
Value: header.value,
}))
: undefined,
Subject: { Data: input.subject, Charset: 'UTF-8' },
Body: {
Text: { Data: input.text, Charset: 'UTF-8' },
@@ -75,10 +77,6 @@ export class AwsSesSendEmailService {
},
ConfigurationSetName: context.configurationSetName,
TenantName: context.tenantName,
ListManagementOptions: {
ContactListName: context.contactListName,
TopicName: AWS_SES_MARKETING_TOPIC_NAME,
},
EmailTags: [
{ Name: 'workspace', Value: input.workspaceId },
{ Name: 'domain', Value: input.domain },
@@ -97,7 +95,14 @@ export class AwsSesSendEmailService {
`Sent email ${response.MessageId} from ${input.from} (tenant ${context.tenantName})`,
);
return { messageId: response.MessageId };
return {
messageId: response.MessageId,
deliveredRecipients: {
to: input.to,
cc: input.cc ?? [],
bcc: input.bcc ?? [],
},
};
} catch (error) {
if (error instanceof EmailingDomainDriverException) {
throw error;
@@ -11,6 +11,8 @@ export enum EmailingDomainDriverExceptionCode {
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONFIGURATION_ERROR = 'CONFIGURATION_ERROR',
SENDING_SUSPENDED = 'SENDING_SUSPENDED',
ALL_RECIPIENTS_SUPPRESSED = 'ALL_RECIPIENTS_SUPPRESSED',
UNSUBSCRIBE_NOT_READY = 'UNSUBSCRIBE_NOT_READY',
UNKNOWN = 'UNKNOWN',
}
@@ -26,6 +28,10 @@ const getEmailingDomainDriverExceptionUserFriendlyMessage = (
return msg`Email domain configuration error.`;
case EmailingDomainDriverExceptionCode.SENDING_SUSPENDED:
return msg`Sending is currently suspended for this email domain.`;
case EmailingDomainDriverExceptionCode.ALL_RECIPIENTS_SUPPRESSED:
return msg`All recipients are suppressed for this email domain.`;
case EmailingDomainDriverExceptionCode.UNSUBSCRIBE_NOT_READY:
return msg`Marketing sending is on hold until the unsubscribe domain is verified.`;
case EmailingDomainDriverExceptionCode.TEMPORARY_ERROR:
case EmailingDomainDriverExceptionCode.UNKNOWN:
return STANDARD_ERROR_MESSAGE;
@@ -1,7 +1,5 @@
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
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 { type EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
@@ -8,10 +8,8 @@ import {
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
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';
@Injectable()
export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
@@ -66,6 +64,13 @@ export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
`[log-driver] sendEmail from=${input.from} to=${input.to.join(',')} subject="${input.subject}" → fake messageId=${messageId}`,
);
return { messageId };
return {
messageId,
deliveredRecipients: {
to: input.to,
cc: input.cc ?? [],
bcc: input.bcc ?? [],
},
};
}
}
@@ -0,0 +1,5 @@
export type EmailingDomainAttachment = {
filename: string;
content: Buffer;
contentType: string;
};
@@ -0,0 +1,16 @@
import { type EmailingDomainAttachment } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-attachment.type';
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-header.type';
export type EmailingDomainEmailContent = {
from: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
text: string;
html?: string;
replyTo?: string[];
attachments?: EmailingDomainAttachment[];
headers?: EmailingDomainHeader[];
unsubscribeTopicId?: string;
};
@@ -0,0 +1,4 @@
export type EmailingDomainHeader = {
name: string;
value: string;
};
@@ -0,0 +1,6 @@
import { type EmailingDomainEmailContent } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-email-content.type';
export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
workspaceId: string;
domain: string;
};
@@ -0,0 +1,4 @@
export type EmailingDomainSendEmailResult = {
messageId: string;
deliveredRecipients: { to: string[]; cc: string[]; bcc: string[] };
};
@@ -1,26 +0,0 @@
export type EmailingDomainAttachment = {
filename: string;
content: Buffer;
contentType: string;
};
export type EmailingDomainEmailContent = {
from: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
text: string;
html?: string;
replyTo?: string[];
attachments?: EmailingDomainAttachment[];
};
export type EmailingDomainSendEmailInput = EmailingDomainEmailContent & {
workspaceId: string;
domain: string;
};
export type EmailingDomainSendEmailResult = {
messageId: string;
};
@@ -0,0 +1,5 @@
export enum UnsubscribeHostnameStatus {
PENDING = 'PENDING',
ACTIVE = 'ACTIVE',
FAILED = 'FAILED',
}
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CampaignAudiencePreviewDTO {
@Field(() => Int)
totalMembers: number;
@Field(() => Int)
withoutEmail: number;
@Field(() => Int)
duplicateEmails: number;
@Field(() => Int)
globallyUnsubscribed: number;
@Field(() => Int)
topicUnsubscribed: number;
@Field(() => Int)
sendable: number;
}
@@ -0,0 +1,13 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CampaignSkippedRecipientsDTO {
@Field(() => Int)
noEmail: number;
@Field(() => Int)
deduped: number;
@Field(() => Int)
overCap: number;
}
@@ -0,0 +1,16 @@
import { Field, InputType } from '@nestjs/graphql';
import { Matches, MaxLength } from 'class-validator';
const DOMAIN_REGEX =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
@InputType()
export class CreateEmailingDomainInput {
@Field(() => String)
@MaxLength(255)
@Matches(DOMAIN_REGEX, {
message: 'domain must be a valid domain name (e.g. mail.example.com)',
})
domain: string;
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
@InputType()
export class CreateUnsubscribeTopicInput {
@Field(() => String)
@IsString()
@MaxLength(256)
name: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(1024)
description?: string;
@Field(() => UnsubscribeTopicVisibility, { nullable: true })
@IsOptional()
@IsEnum(UnsubscribeTopicVisibility)
visibility?: UnsubscribeTopicVisibility;
}
@@ -0,0 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsUUID } from 'class-validator';
@InputType()
export class PreviewMessageCampaignAudienceInput {
@Field(() => String)
@IsUUID('4')
listId: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID('4')
unsubscribeTopicId?: string;
}
@@ -0,0 +1,15 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { CampaignSkippedRecipientsDTO } from 'src/engine/core-modules/emailing-domain/dtos/campaign-skipped-recipients.dto';
@ObjectType()
export class SendMessageCampaignOutputDTO {
@Field(() => String)
campaignId: string;
@Field(() => Int)
queuedCount: number;
@Field(() => CampaignSkippedRecipientsDTO)
skipped: CampaignSkippedRecipientsDTO;
}
@@ -0,0 +1,36 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsEmail,
IsOptional,
IsString,
IsUUID,
Length,
MinLength,
} from 'class-validator';
@InputType()
export class SendMessageCampaignInput {
@Field(() => String)
@IsUUID('4')
listId: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID('4')
unsubscribeTopicId?: string;
@Field(() => String)
@IsString()
@Length(1, 998)
subject: string;
@Field(() => String)
@IsString()
@MinLength(1)
body: string;
@Field(() => String)
@IsEmail()
fromAddress: string;
}
@@ -0,0 +1,31 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
registerEnumType(UnsubscribeTopicVisibility, {
name: 'UnsubscribeTopicVisibility',
});
@ObjectType('UnsubscribeTopic')
export class UnsubscribeTopicDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
@Field(() => String, { nullable: true })
name: string | null;
@Field(() => String, { nullable: true })
description: string | null;
@Field(() => UnsubscribeTopicVisibility)
visibility: UnsubscribeTopicVisibility;
}
@@ -0,0 +1,35 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
@InputType()
export class UpdateUnsubscribeTopicInput {
@Field(() => String)
@IsUUID('4')
id: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(256)
name?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(1024)
description?: string;
@Field(() => UnsubscribeTopicVisibility, { nullable: true })
@IsOptional()
@IsEnum(UnsubscribeTopicVisibility)
visibility?: UnsubscribeTopicVisibility;
}
@@ -11,6 +11,7 @@ import {
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 { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@@ -51,4 +52,17 @@ export class EmailingDomainEntity extends WorkspaceRelatedEntity {
nullable: false,
})
tenantStatus: EmailingDomainTenantStatus;
@Column({ type: 'varchar', nullable: true })
unsubscribeHostname: string | null;
@Column({ type: 'varchar', nullable: true })
unsubscribeHostnameId: string | null;
@Column({
type: 'enum',
enum: Object.values(UnsubscribeHostnameStatus),
nullable: true,
})
unsubscribeHostnameStatus: UnsubscribeHostnameStatus | null;
}
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesRegisterDomainService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
@@ -14,20 +15,33 @@ import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/
import { EmailingDomainWorkspaceCleanupJob } from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job';
import { EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@Module({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature([EmailingDomainEntity]),
FeatureFlagModule,
PermissionsModule,
DnsManagerModule,
SecretEncryptionModule,
],
exports: [
EmailingDomainService,
EmailingDomainTenantStatusService,
EmailingDomainDriverFactory,
UnsubscribeTokenService,
],
exports: [EmailingDomainService, EmailingDomainTenantStatusService],
providers: [
EmailingDomainService,
EmailingDomainTenantStatusService,
UnsubscribeTokenService,
UnsubscribeHostnameService,
EmailingDomainResolver,
EmailingDomainDriverFactory,
EmailingDomainWorkspaceCleanupJob,
@@ -5,9 +5,8 @@ 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 { CreateEmailingDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/create-emailing-domain.input';
import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { SendEmailViaDomainOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto';
import { SendEmailViaDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -32,13 +31,13 @@ export class EmailingDomainResolver {
@Mutation(() => EmailingDomainDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('input') input: CreateEmailingDomainInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDTO> {
const emailingDomain =
await this.emailingDomainService.createEmailingDomain(
domain,
currentWorkspace,
input.domain.trim().toLowerCase(),
currentWorkspace.id,
);
return emailingDomain;
@@ -70,22 +69,6 @@ export class EmailingDomainResolver {
return emailingDomain;
}
@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.emailingDomainService.sendEmail(
currentWorkspace.id,
emailingDomainId,
content,
);
return { messageId: result.messageId };
}
@Query(() => [EmailingDomainDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async getEmailingDomains(
@@ -0,0 +1,65 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
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 { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
// Two partial indexes because Postgres treats NULLs as distinct: one global
// block per (workspace, address), one opt-out per (workspace, address, topic).
// NOT decorated with @WasIntroducedInUpgrade: a missing-table error that fails
// sends is safer than silently skipping suppression during a deploy window.
@Entity({ name: 'messageSuppression', schema: 'core' })
@Index(
'IDX_MESSAGE_SUPPRESSION_GLOBAL_UNIQUE',
['workspaceId', 'emailAddress'],
{
unique: true,
where: '"unsubscribeTopicId" IS NULL',
},
)
@Index(
'IDX_MESSAGE_SUPPRESSION_TOPIC_UNIQUE',
['workspaceId', 'emailAddress', 'unsubscribeTopicId'],
{ unique: true, where: '"unsubscribeTopicId" IS NOT NULL' },
)
@Index('IDX_MESSAGE_SUPPRESSION_WORKSPACE_ID', ['workspaceId'])
export class MessageSuppressionEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'varchar', nullable: false })
emailAddress: string;
@Column({
type: 'enum',
enum: Object.values(MessageSuppressionReason),
nullable: false,
})
reason: MessageSuppressionReason;
@Column({
type: 'enum',
enum: Object.values(MessageSuppressionSource),
nullable: false,
})
source: MessageSuppressionSource;
@Column({ type: 'varchar', nullable: true })
providerEventId: string | null;
@Column({ type: 'uuid', nullable: true })
unsubscribeTopicId: string | null;
}
@@ -1,97 +0,0 @@
import { EmailingDomainDriverExceptionCode } from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
import { type 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 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 { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
describe('EmailingDomainService.sendEmail', () => {
const buildEmailingDomain = (
overrides: Partial<EmailingDomainEntity> = {},
): EmailingDomainEntity =>
({
id: 'domain-1',
workspaceId: 'ws1',
domain: 'mail.example.com',
status: EmailingDomainStatus.VERIFIED,
tenantStatus: EmailingDomainTenantStatus.ACTIVE,
...overrides,
}) as EmailingDomainEntity;
const buildEmailContent = () => ({
from: 'hello@mail.example.com',
to: ['user@example.com'],
subject: 'Hi',
text: 'Body',
});
const setUp = (emailingDomain: EmailingDomainEntity) => {
const sendEmail = jest.fn().mockResolvedValue({ messageId: 'msg-1' });
const repository = {
findOne: jest.fn().mockResolvedValue(emailingDomain),
} as unknown as WorkspaceScopedRepository<EmailingDomainEntity>;
const factory = {
getCurrentDriver: () => ({ sendEmail }),
} as unknown as EmailingDomainDriverFactory;
const service = new EmailingDomainService(repository, factory);
return { service, sendEmail };
};
it('delegates to the driver when the domain is verified and the tenant is active', async () => {
const { service, sendEmail } = setUp(buildEmailingDomain());
const result = await service.sendEmail(
'ws1',
'domain-1',
buildEmailContent(),
);
expect(result.messageId).toBe('msg-1');
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'ws1',
domain: 'mail.example.com',
from: 'hello@mail.example.com',
}),
);
});
it.each([
EmailingDomainTenantStatus.PAUSED,
EmailingDomainTenantStatus.PERMANENTLY_SUSPENDED,
])(
'rejects sending with SENDING_SUSPENDED when tenantStatus is %s, without calling the driver',
async (tenantStatus) => {
const { service, sendEmail } = setUp(
buildEmailingDomain({ tenantStatus }),
);
await expect(
service.sendEmail('ws1', 'domain-1', buildEmailContent()),
).rejects.toMatchObject({
code: EmailingDomainDriverExceptionCode.SENDING_SUSPENDED,
});
expect(sendEmail).not.toHaveBeenCalled();
},
);
// Verification is a precondition for the tenant-status check: a domain that
// has not been verified should surface a CONFIGURATION_ERROR rather than
// leaking the tenant pause state to callers who couldn't have used it anyway.
it('reports the verification failure before the tenant-status failure', async () => {
const { service } = setUp(
buildEmailingDomain({
status: EmailingDomainStatus.PENDING,
tenantStatus: EmailingDomainTenantStatus.PAUSED,
}),
);
await expect(
service.sendEmail('ws1', 'domain-1', buildEmailContent()),
).rejects.toMatchObject({
code: EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
});
});
});
@@ -1,20 +1,19 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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';
@Injectable()
export class EmailingDomainService {
private readonly logger = new Logger(EmailingDomainService.name);
@@ -23,14 +22,15 @@ export class EmailingDomainService {
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
private readonly unsubscribeHostnameService: UnsubscribeHostnameService,
) {}
async createEmailingDomain(
domain: string,
workspace: WorkspaceEntity,
workspaceId: string,
): Promise<EmailingDomainEntity> {
const existingEmailingDomain = await this.emailingDomainRepository.findOne(
workspace.id,
workspaceId,
{
where: { domain },
},
@@ -46,26 +46,81 @@ export class EmailingDomainService {
const emailingDomainDriver =
this.emailingDomainDriverFactory.getCurrentDriver();
await emailingDomainDriver.provisionWorkspace(workspace.id);
await emailingDomainDriver.provisionWorkspace(workspaceId);
const verificationResult = await emailingDomainDriver.verifyDomain({
domain,
workspaceId: workspace.id,
workspaceId,
});
await emailingDomainDriver.registerDomain({
domain,
workspaceId: workspace.id,
workspaceId,
});
const isVerifiedOnCreation =
verificationResult.status === EmailingDomainStatus.VERIFIED;
return this.emailingDomainRepository.save(workspace.id, {
domain,
status: verificationResult.status,
verificationRecords: verificationResult.verificationRecords,
verifiedAt: isVerifiedOnCreation ? new Date() : null,
const emailingDomain = await this.emailingDomainRepository.save(
workspaceId,
{
domain,
status: verificationResult.status,
verificationRecords: verificationResult.verificationRecords,
verifiedAt: isVerifiedOnCreation ? new Date() : null,
},
);
if (isVerifiedOnCreation) {
await this.unsubscribeHostnameService.sync(
workspaceId,
emailingDomain.id,
{
provision: true,
},
);
}
return this.unsubscribeHostnameService.withDnsRecords(
await this.emailingDomainRepository.findOneOrFail(workspaceId, {
where: { id: emailingDomain.id },
}),
);
}
async ensureEmailingDomain(
domain: string,
workspaceId: string,
): Promise<void> {
const existingEmailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { domain } },
);
if (isDefined(existingEmailingDomain)) {
return;
}
await this.createEmailingDomain(domain, workspaceId);
}
async deleteEmailingDomainByDomainIfExists(
workspaceId: string,
domain: string,
): Promise<void> {
const emailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { domain } },
);
if (!isDefined(emailingDomain)) {
return;
}
await this.unsubscribeHostnameService.deprovision(emailingDomain);
await this.deleteRemoteEmailingDomain(emailingDomain);
await this.emailingDomainRepository.delete(workspaceId, {
id: emailingDomain.id,
});
}
@@ -78,6 +133,7 @@ export class EmailingDomainService {
emailingDomainId,
);
await this.unsubscribeHostnameService.deprovision(emailingDomain);
await this.deleteRemoteEmailingDomain(emailingDomain);
await this.emailingDomainRepository.delete(workspace.id, {
id: emailingDomain.id,
@@ -113,9 +169,18 @@ export class EmailingDomainService {
async getEmailingDomains(
workspace: WorkspaceEntity,
): Promise<EmailingDomainEntity[]> {
return this.emailingDomainRepository.find(workspace.id, {
order: { createdAt: 'DESC' },
});
const emailingDomains = await this.emailingDomainRepository.find(
workspace.id,
{
order: { createdAt: 'DESC' },
},
);
return Promise.all(
emailingDomains.map((emailingDomain) =>
this.unsubscribeHostnameService.withDnsRecords(emailingDomain),
),
);
}
async verifyEmailingDomain(
@@ -149,49 +214,19 @@ export class EmailingDomainService {
},
);
return this.emailingDomainRepository.findOneOrFail(workspace.id, {
where: { id: emailingDomain.id },
});
}
async sendEmail(
workspaceId: string,
emailingDomainId: string,
emailContent: EmailingDomainEmailContent,
): Promise<EmailingDomainSendEmailResult> {
const emailingDomain = await this.findEmailingDomainByIdOrThrow(
workspaceId,
emailingDomainId,
await this.unsubscribeHostnameService.sync(
workspace.id,
emailingDomain.id,
{
provision: verificationResult.status === EmailingDomainStatus.VERIFIED,
},
);
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 = emailContent.from.split('@')[1]?.toLowerCase();
if (fromAddressDomain !== emailingDomain.domain.toLowerCase()) {
throw new EmailingDomainDriverException(
`From address ${emailContent.from} does not match verified domain ${emailingDomain.domain}`,
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
);
}
return this.emailingDomainDriverFactory.getCurrentDriver().sendEmail({
...emailContent,
workspaceId,
domain: emailingDomain.domain,
});
return this.unsubscribeHostnameService.withDnsRecords(
await this.emailingDomainRepository.findOneOrFail(workspace.id, {
where: { id: emailingDomain.id },
}),
);
}
private async findEmailingDomainByIdOrThrow(
@@ -0,0 +1,181 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import { UNSUBSCRIBE_HOSTNAME_PREFIX } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-hostname-prefix.constant';
import { UnsubscribeHostnameStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/unsubscribe-hostname-status.type';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
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';
@Injectable()
export class UnsubscribeHostnameService {
private readonly logger = new Logger(UnsubscribeHostnameService.name);
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly dnsManagerService: DnsManagerService,
) {}
async provision(emailingDomain: EmailingDomainEntity): Promise<void> {
if (isNonEmptyString(emailingDomain.unsubscribeHostnameId)) {
return;
}
const hostname = this.buildHostname(emailingDomain.domain);
const unsubscribeHostnameId = await this.registerOrAdoptHostname(hostname);
await this.emailingDomainRepository.update(
emailingDomain.workspaceId,
{ id: emailingDomain.id },
{
unsubscribeHostname: hostname,
unsubscribeHostnameId,
unsubscribeHostnameStatus: UnsubscribeHostnameStatus.PENDING,
},
);
}
private async registerOrAdoptHostname(hostname: string): Promise<string> {
try {
const createdHostname =
await this.dnsManagerService.registerHostname(hostname);
return createdHostname.id;
} catch (error) {
if (
error instanceof DnsManagerException &&
error.code === DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED
) {
const existingHostnameId =
await this.dnsManagerService.getHostnameId(hostname);
if (isNonEmptyString(existingHostnameId)) {
return existingHostnameId;
}
}
throw error;
}
}
async refreshStatus(emailingDomain: EmailingDomainEntity): Promise<void> {
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
return;
}
const isWorking = await this.dnsManagerService.isHostnameWorking(
emailingDomain.unsubscribeHostname,
);
await this.emailingDomainRepository.update(
emailingDomain.workspaceId,
{ id: emailingDomain.id },
{
unsubscribeHostnameStatus: isWorking
? UnsubscribeHostnameStatus.ACTIVE
: UnsubscribeHostnameStatus.PENDING,
},
);
}
async deprovision(emailingDomain: EmailingDomainEntity): Promise<void> {
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
return;
}
await this.dnsManagerService.deleteHostnameSilently(
emailingDomain.unsubscribeHostname,
);
}
async sync(
workspaceId: string,
emailingDomainId: string,
{ provision }: { provision: boolean },
): Promise<void> {
try {
const emailingDomain = await this.emailingDomainRepository.findOneOrFail(
workspaceId,
{ where: { id: emailingDomainId } },
);
if (provision) {
await this.provision(emailingDomain);
}
await this.refreshStatus(
await this.emailingDomainRepository.findOneOrFail(workspaceId, {
where: { id: emailingDomainId },
}),
);
} catch (error) {
this.logger.warn(
`Failed to sync unsubscribe hostname for emailing domain ${emailingDomainId}: ${error}`,
);
}
}
async withDnsRecords(
emailingDomain: EmailingDomainEntity,
): Promise<EmailingDomainEntity> {
const unsubscribeRecords = await this.getDnsRecords(emailingDomain);
if (unsubscribeRecords.length === 0) {
return emailingDomain;
}
return {
...emailingDomain,
verificationRecords: [
...(emailingDomain.verificationRecords ?? []),
...unsubscribeRecords,
],
};
}
async getDnsRecords(
emailingDomain: EmailingDomainEntity,
): Promise<VerificationRecord[]> {
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
return [];
}
try {
const hostnameWithRecords =
await this.dnsManagerService.getHostnameWithRecords(
emailingDomain.unsubscribeHostname,
);
if (!isDefined(hostnameWithRecords)) {
return [];
}
return hostnameWithRecords.records.map((record) => ({
type: 'CNAME' as const,
key: record.key,
value: record.value,
}));
} catch (error) {
this.logger.warn(
`Failed to read unsubscribe DNS records for ${emailingDomain.unsubscribeHostname}: ${error}`,
);
return [];
}
}
private buildHostname(domain: string): string {
return `${UNSUBSCRIBE_HOSTNAME_PREFIX}.${domain}`;
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type UnsubscribeTokenPayload } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-token-payload.type';
@Injectable()
export class UnsubscribeTokenService {
constructor(
private readonly secretEncryptionService: SecretEncryptionService,
) {}
sign(payload: Omit<UnsubscribeTokenPayload, 'issuedAt'>): string {
const stampedPayload: UnsubscribeTokenPayload = {
...payload,
issuedAt: Date.now(),
};
return Buffer.from(
this.secretEncryptionService.encryptVersioned(
JSON.stringify(stampedPayload) as PlaintextString,
),
).toString('base64url');
}
verify(token: string): UnsubscribeTokenPayload | null {
try {
const decrypted = this.secretEncryptionService.decryptVersioned(
Buffer.from(token, 'base64url').toString('utf8') as EncryptedString,
);
const decoded = JSON.parse(decrypted);
if (
typeof decoded?.workspaceId !== 'string' ||
typeof decoded?.emailAddress !== 'string'
) {
return null;
}
return {
workspaceId: decoded.workspaceId,
emailAddress: decoded.emailAddress,
issuedAt: typeof decoded?.issuedAt === 'number' ? decoded.issuedAt : 0,
...(typeof decoded?.unsubscribeTopicId === 'string'
? { unsubscribeTopicId: decoded.unsubscribeTopicId }
: {}),
...(decoded?.preview === true ? { preview: true } : {}),
};
} catch {
return null;
}
}
}
@@ -0,0 +1,4 @@
export type CampaignRecipient = {
personId: string;
email: string;
};
@@ -0,0 +1,5 @@
export type CampaignSkippedBreakdown = {
noEmail: number;
deduped: number;
overCap: number;
};
@@ -0,0 +1,5 @@
export type DeliverableRecipients = {
to: string[];
cc?: string[];
bcc?: string[];
};
@@ -0,0 +1,9 @@
import { type CampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/campaign-recipient.type';
export type MaterializeCampaignJobData = {
workspaceId: string;
campaignId: string;
messageChannelId: string;
emailingDomainId: string;
recipients: CampaignRecipient[];
};
@@ -0,0 +1,5 @@
export enum MessageSuppressionReason {
BOUNCE = 'BOUNCE',
COMPLAINT = 'COMPLAINT',
UNSUBSCRIBE = 'UNSUBSCRIBE',
}
@@ -0,0 +1,4 @@
export enum MessageSuppressionSource {
WEBHOOK = 'WEBHOOK',
SYSTEM = 'SYSTEM',
}
@@ -0,0 +1,4 @@
export type RawCampaignRecipient = {
personId: string;
email: string | null;
};
@@ -0,0 +1,8 @@
export type SendCampaignEmailJobData = {
workspaceId: string;
campaignId: string;
messageId: string;
personId: string;
recipientEmail: string;
emailingDomainId: string;
};
@@ -0,0 +1,5 @@
export type TopicOptOutState = {
unsubscribeTopicId: string;
topicName: string | null;
optedOut: boolean;
};
@@ -0,0 +1,7 @@
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-header.type';
export type UnsubscribeContent = {
headers: EmailingDomainHeader[];
textFooter: string;
htmlFooter: string;
};
@@ -0,0 +1,7 @@
export type UnsubscribeTokenPayload = {
workspaceId: string;
emailAddress: string;
unsubscribeTopicId?: string;
preview?: boolean;
issuedAt: number;
};
@@ -0,0 +1,4 @@
export enum UnsubscribeTopicVisibility {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}
@@ -0,0 +1,4 @@
export type UnsubscribeUrls = {
httpsUrl: string;
mailtoUrl: string;
};
@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { UnsubscribeTopicVisibility } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-topic-visibility.type';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'unsubscribeTopic', schema: 'core' })
@Index('IDX_UNSUBSCRIBE_TOPIC_WORKSPACE_ID', ['workspaceId'])
export class UnsubscribeTopicEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'varchar', nullable: true })
name: string | null;
@Column({ type: 'varchar', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: Object.values(UnsubscribeTopicVisibility),
default: UnsubscribeTopicVisibility.PRIVATE,
nullable: false,
})
visibility: UnsubscribeTopicVisibility;
}
@@ -0,0 +1,49 @@
import { type RawCampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/raw-campaign-recipient.type';
import { normalizeCampaignRecipients } from 'src/engine/core-modules/emailing-domain/utils/normalize-campaign-recipients.util';
describe('normalizeCampaignRecipients', () => {
it('drops people with no email and reports them', () => {
const raw: RawCampaignRecipient[] = [
{ personId: 'p1', email: 'a@example.com' },
{ personId: 'p2', email: null },
{ personId: 'p3', email: ' ' },
];
const { recipients, skipped } = normalizeCampaignRecipients(raw, 100);
expect(recipients).toEqual([{ personId: 'p1', email: 'a@example.com' }]);
expect(skipped).toEqual({ noEmail: 2, deduped: 0, overCap: 0 });
});
it('dedupes by lowercased email, keeping the first occurrence', () => {
const raw: RawCampaignRecipient[] = [
{ personId: 'p1', email: 'A@Example.com' },
{ personId: 'p2', email: 'a@example.com' },
];
const { recipients, skipped } = normalizeCampaignRecipients(raw, 100);
expect(recipients).toEqual([{ personId: 'p1', email: 'a@example.com' }]);
expect(skipped.deduped).toBe(1);
});
it('caps the recipient count and reports the overflow', () => {
const raw: RawCampaignRecipient[] = [
{ personId: 'p1', email: 'a@example.com' },
{ personId: 'p2', email: 'b@example.com' },
{ personId: 'p3', email: 'c@example.com' },
];
const { recipients, skipped } = normalizeCampaignRecipients(raw, 2);
expect(recipients).toHaveLength(2);
expect(skipped.overCap).toBe(1);
});
it('returns an empty result for no input', () => {
expect(normalizeCampaignRecipients([], 100)).toEqual({
recipients: [],
skipped: { noEmail: 0, deduped: 0, overCap: 0 },
});
});
});
@@ -0,0 +1,10 @@
import { type EmailingDomainHeader } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-header.type';
import { type UnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-urls.type';
export const buildUnsubscribeHeaders = ({
httpsUrl,
mailtoUrl,
}: UnsubscribeUrls): EmailingDomainHeader[] => [
{ name: 'List-Unsubscribe', value: `<${httpsUrl}>, <${mailtoUrl}>` },
{ name: 'List-Unsubscribe-Post', value: 'List-Unsubscribe=One-Click' },
];
@@ -0,0 +1,2 @@
export const buildUnsubscribeHtmlFooter = (httpsUrl: string): string =>
`<hr style="margin-top:24px;border:none;border-top:1px solid #eee" /><p style="font-size:12px;color:#888">Don't want these emails? <a href="${httpsUrl}">Unsubscribe</a>.</p>`;
@@ -0,0 +1,39 @@
import { type TopicOptOutState } from 'src/engine/core-modules/emailing-domain/types/topic-opt-out-state.type';
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
type BuildUnsubscribePreferencesPageArgs = {
token: string;
topics: TopicOptOutState[];
updatePath: string;
unsubscribeAllPath: string;
};
const PAGE_STYLE = `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fafafa;margin:0;padding:48px 16px;color:#1a1a1a}.card{max-width:420px;margin:0 auto;background:#fff;border:1px solid #ededed;border-radius:16px;padding:40px 32px;text-align:center}h1{font-size:28px;font-weight:700;margin:0 0 8px}.subtitle{color:#888;margin:0 0 28px}.topics{text-align:left;margin:0 0 28px}.topic{display:flex;align-items:center;gap:12px;padding:10px 0;font-size:16px}.topic input{width:18px;height:18px;accent-color:#1a1a1a}button{width:100%;border-radius:10px;padding:14px;font-size:16px;font-weight:600;cursor:pointer;border:1px solid transparent}.primary{background:#1a1a1a;color:#fff}.divider{color:#aaa;margin:16px 0}.secondary{background:#fff;color:#1a1a1a;border:1px solid #ddd}`;
const buildTopicCheckbox = (topic: TopicOptOutState): string => {
const label = escapeHtml(topic.topicName ?? 'Untitled topic');
const value = escapeHtml(topic.unsubscribeTopicId);
const checkedAttribute = topic.optedOut ? '' : ' checked';
return `<label class="topic"><input type="checkbox" name="unsubscribeTopicId" value="${value}"${checkedAttribute} />${label}</label>`;
};
export const buildUnsubscribePreferencesPage = ({
token,
topics,
updatePath,
unsubscribeAllPath,
}: BuildUnsubscribePreferencesPageArgs): string => {
const safeToken = escapeHtml(token);
const updateSection =
topics.length > 0
? `<form method="post" action="${updatePath}"><input type="hidden" name="t" value="${safeToken}" /><div class="topics">${topics
.map(buildTopicCheckbox)
.join(
'',
)}</div><button type="submit" class="primary">Update</button></form><p class="divider">Or</p>`
: '';
return `<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Email preferences</title><style>${PAGE_STYLE}</style></head><body><div class="card"><h1>Do you want to unsubscribe?</h1><p class="subtitle">Confirm your preferences:</p>${updateSection}<form method="post" action="${unsubscribeAllPath}"><input type="hidden" name="t" value="${safeToken}" /><button type="submit" class="secondary">Unsubscribe All</button></form></div></body></html>`;
};
@@ -0,0 +1,13 @@
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
const PAGE_STYLE = `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fafafa;margin:0;padding:48px 16px;color:#1a1a1a;text-align:center}.card{max-width:420px;margin:0 auto;background:#fff;border:1px solid #ededed;border-radius:16px;padding:48px 32px}h1{font-size:24px;font-weight:700;margin:0 0 8px}p{color:#888;margin:0}`;
export const buildUnsubscribeResultPage = (
title: string,
message: string,
): string =>
`<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(
title,
)}</title><style>${PAGE_STYLE}</style></head><body><div class="card"><h1>${escapeHtml(
title,
)}</h1><p>${escapeHtml(message)}</p></div></body></html>`;
@@ -0,0 +1,2 @@
export const buildUnsubscribeTextFooter = (httpsUrl: string): string =>
`\n\n--\nUnsubscribe: ${httpsUrl}`;
@@ -0,0 +1,17 @@
import { UNSUBSCRIBE_MAILBOX_LOCAL_PART } from 'src/engine/core-modules/emailing-domain/constants/unsubscribe-mailbox.constant';
import { type UnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-urls.type';
type BuildUnsubscribeUrlsArgs = {
unsubscribeHostname: string;
domain: string;
token: string;
};
export const buildUnsubscribeUrls = ({
unsubscribeHostname,
domain,
token,
}: BuildUnsubscribeUrlsArgs): UnsubscribeUrls => ({
httpsUrl: `https://${unsubscribeHostname}/emailing/unsubscribe?t=${token}`,
mailtoUrl: `mailto:${UNSUBSCRIBE_MAILBOX_LOCAL_PART}@${domain}?subject=${token}`,
});
@@ -0,0 +1,10 @@
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export const escapeHtml = (value: string): string =>
value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
@@ -0,0 +1,43 @@
import { isNonEmptyString } from '@sniptt/guards';
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 RawCampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/raw-campaign-recipient.type';
export const normalizeCampaignRecipients = (
rawRecipients: RawCampaignRecipient[],
maxRecipients: number,
): { recipients: CampaignRecipient[]; skipped: CampaignSkippedBreakdown } => {
const skipped: CampaignSkippedBreakdown = {
noEmail: 0,
deduped: 0,
overCap: 0,
};
const seenEmails = new Set<string>();
const recipients: CampaignRecipient[] = [];
for (const candidate of rawRecipients) {
const normalizedEmail = candidate.email?.trim().toLowerCase();
if (!isNonEmptyString(normalizedEmail)) {
skipped.noEmail += 1;
continue;
}
if (seenEmails.has(normalizedEmail)) {
skipped.deduped += 1;
continue;
}
seenEmails.add(normalizedEmail);
if (recipients.length >= maxRecipients) {
skipped.overCap += 1;
continue;
}
recipients.push({ email: normalizedEmail, personId: candidate.personId });
}
return { recipients, skipped };
};
@@ -12,7 +12,9 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { MaterializeCampaignJob } from 'src/modules/emailing/jobs/materialize-campaign.job';
import { SendCampaignEmailJob } from 'src/modules/emailing/jobs/send-campaign-email.job';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { EventLogIngestionModule } from 'src/engine/core-modules/event-logs/ingest/event-log-ingestion.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -80,12 +82,14 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
AiChatModule,
LogicFunctionModule,
EnterpriseModule,
EmailingDomainModule,
EmailingModule,
],
providers: [
CleanSuspendedWorkspacesJob,
CleanOnboardingWorkspacesJob,
EmailSenderJob,
SendCampaignEmailJob,
MaterializeCampaignJob,
UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob,
CleanWorkspaceDeletionWarningUserVarsJob,
@@ -1,29 +0,0 @@
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/engine/core-modules/messaging-webhooks/messaging-webhook.exception';
import { getMessagingWebhookExceptionStatusCode } from 'src/engine/core-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),
);
}
}
@@ -1,9 +0,0 @@
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',
}
@@ -1,39 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/engine/core-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),
});
}
}
@@ -1,61 +0,0 @@
import {
Controller,
HttpCode,
Post,
type RawBodyRequest,
Req,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { type Request } from 'express';
import { MessagingWebhookApiExceptionFilter } from 'src/engine/core-modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook.exception';
import { SesInboundWebhookRouterService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service';
import { SesOutboundWebhookRouterService } from 'src/engine/core-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);
}
}
@@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { MessagingWebhooksController } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller';
import { SesInboundMailHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service';
import { SesInboundWebhookRouterService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service';
import { SesOutboundSendingStateHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { SesOutboundWebhookRouterService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service';
import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Module({
imports: [TwentyConfigModule, EmailingDomainModule],
controllers: [MessagingWebhooksController],
providers: [
SnsSignatureVerifierService,
SnsSubscriptionConfirmerService,
SesInboundMailHandlerService,
SesOutboundSendingStateHandlerService,
SesInboundWebhookRouterService,
SesOutboundWebhookRouterService,
],
})
export class MessagingWebhooksModule {}
@@ -1,84 +0,0 @@
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/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { type SesEventBridgeNotification } from 'src/engine/core-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();
});
});
@@ -1,44 +0,0 @@
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 { type SesInboundNotification } from 'src/engine/core-modules/messaging-webhooks/types/sns-message.type';
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,
) {}
async handle(
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 },
);
}
}
@@ -1,62 +0,0 @@
import { Injectable } from '@nestjs/common';
import type SnsPayloadValidator from 'sns-payload-validator';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook.exception';
import { SesInboundMailHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service';
import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { type SesInboundNotification } from 'src/engine/core-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,
);
}
}
@@ -1,58 +0,0 @@
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/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type';
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util';
import { isDefined, isNonEmptyArray } 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 = this.resolveWorkspaceIdFromResources(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,
);
}
private resolveWorkspaceIdFromResources(
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;
}
}
@@ -1,59 +0,0 @@
import { Injectable } from '@nestjs/common';
import type SnsPayloadValidator from 'sns-payload-validator';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook.exception';
import { SesOutboundSendingStateHandlerService } from 'src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service';
import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
import { SnsSubscriptionConfirmerService } from 'src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service';
import { type SesEventBridgeNotification } from 'src/engine/core-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,
) {}
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,
);
}
await this.sesOutboundSendingStateHandlerService.handle(event);
}
}
@@ -1,58 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import SnsPayloadValidator from 'sns-payload-validator';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/engine/core-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);
}
}
@@ -1,39 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { MessagingWebhookException } from 'src/engine/core-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}`);
}
}
@@ -1,15 +0,0 @@
export type SesEventBridgeNotification = {
source: 'aws.ses';
'detail-type': 'Sending Status Enabled' | 'Sending Status Disabled';
resources?: string[];
detail?: {
version?: string;
data?: {
origin?: string;
record?: {
status?: 'ENABLED' | 'DISABLED';
cause?: string;
};
};
};
};
@@ -1,5 +0,0 @@
import { type SESMessage } from 'aws-lambda';
export type SesInboundNotification = SESMessage & {
notificationType?: string;
};
@@ -1,53 +0,0 @@
import { parseWorkspaceIdFromAwsSesResourceArn } from 'src/engine/core-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();
});
});
@@ -1,24 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { MessagingWebhookExceptionCode } from 'src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum';
import { type MessagingWebhookException } from 'src/engine/core-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);
}
}
};
@@ -1,29 +0,0 @@
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;
};
@@ -362,9 +362,12 @@ export class EmailComposerService {
workspaceId,
);
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
const messageChannel =
connectedAccount.provider === ConnectedAccountProvider.EMAIL_GROUP
? connectedAccount.messageChannels[0]
: connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
);
const isSmtpOnlyAccount =
connectedAccount.provider === ConnectedAccountProvider.IMAP_SMTP_CALDAV &&
@@ -1701,12 +1701,12 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Driver used for the emailing domain feature — AWS_SES for production, LOG for local development (no AWS credentials needed)',
'Driver used for the emailing domain feature — AWS_SES for production (requires AWS credentials), LOG fakes registration/verification/sends locally',
type: ConfigVariableType.ENUM,
options: Object.values(EmailingDomainDriver),
})
@CastToUpperSnakeCase()
EMAILING_DOMAIN_DRIVER: EmailingDomainDriver = EmailingDomainDriver.AWS_SES;
EMAILING_DOMAIN_DRIVER: EmailingDomainDriver = EmailingDomainDriver.LOG;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
@@ -41,7 +41,7 @@ import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspac
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntity> {
private readonly logger = new Logger(UserWorkspaceService.name);
@@ -357,7 +357,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
const workspacesFromApprovedAccessDomain = (
await this.approvedAccessDomainService.findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain(
getDomainNameByEmail(email),
getDomainFromEmailOrThrow(email),
)
)
.filter(