[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:
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ describe('ClientConfigController', () => {
|
||||
isGoogleCalendarEnabled: false,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isEmailGroupEnabled: false,
|
||||
isEmailingDomainInDemoMode: false,
|
||||
calendarBookingPageId: undefined,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
allowRequestsToTwentyIcons: true,
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ export class ClientConfig {
|
||||
isImapSmtpCaldavEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailGroupEnabled: boolean;
|
||||
isEmailingDomainInDemoMode: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+2
-2
@@ -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();
|
||||
};
|
||||
|
||||
+24
@@ -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';
|
||||
+7
@@ -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: '',
|
||||
};
|
||||
+11
@@ -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,
|
||||
];
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_HOSTNAME_PREFIX = 'unsubscribe';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UNSUBSCRIBE_MAILBOX_LOCAL_PART = 'unsubscribe';
|
||||
-1
@@ -1 +0,0 @@
|
||||
export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
|
||||
-4
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
+2
-6
@@ -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' },
|
||||
|
||||
+2
-18
@@ -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();
|
||||
|
||||
|
||||
+1
-24
@@ -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({
|
||||
|
||||
+16
-11
@@ -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;
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
+2
-4
@@ -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';
|
||||
|
||||
|
||||
+10
-5
@@ -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 ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type EmailingDomainAttachment = {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
+16
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EmailingDomainHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
+6
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EmailingDomainSendEmailResult = {
|
||||
messageId: string;
|
||||
deliveredRecipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
};
|
||||
-26
@@ -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;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum UnsubscribeHostnameStatus {
|
||||
PENDING = 'PENDING',
|
||||
ACTIVE = 'ACTIVE',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
+15
-1
@@ -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,
|
||||
|
||||
+4
-21
@@ -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(
|
||||
|
||||
+65
@@ -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;
|
||||
}
|
||||
-97
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+94
-59
@@ -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(
|
||||
|
||||
+181
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type CampaignRecipient = {
|
||||
personId: string;
|
||||
email: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type CampaignSkippedBreakdown = {
|
||||
noEmail: number;
|
||||
deduped: number;
|
||||
overCap: number;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type DeliverableRecipients = {
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
};
|
||||
+9
@@ -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[];
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum MessageSuppressionReason {
|
||||
BOUNCE = 'BOUNCE',
|
||||
COMPLAINT = 'COMPLAINT',
|
||||
UNSUBSCRIBE = 'UNSUBSCRIBE',
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export enum MessageSuppressionSource {
|
||||
WEBHOOK = 'WEBHOOK',
|
||||
SYSTEM = 'SYSTEM',
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type RawCampaignRecipient = {
|
||||
personId: string;
|
||||
email: string | null;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type SendCampaignEmailJobData = {
|
||||
workspaceId: string;
|
||||
campaignId: string;
|
||||
messageId: string;
|
||||
personId: string;
|
||||
recipientEmail: string;
|
||||
emailingDomainId: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type TopicOptOutState = {
|
||||
unsubscribeTopicId: string;
|
||||
topicName: string | null;
|
||||
optedOut: boolean;
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type UnsubscribeTokenPayload = {
|
||||
workspaceId: string;
|
||||
emailAddress: string;
|
||||
unsubscribeTopicId?: string;
|
||||
preview?: boolean;
|
||||
issuedAt: number;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export enum UnsubscribeTopicVisibility {
|
||||
PUBLIC = 'PUBLIC',
|
||||
PRIVATE = 'PRIVATE',
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type UnsubscribeUrls = {
|
||||
httpsUrl: string;
|
||||
mailtoUrl: string;
|
||||
};
|
||||
+38
@@ -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;
|
||||
}
|
||||
+49
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
@@ -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' },
|
||||
];
|
||||
+2
@@ -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>`;
|
||||
+39
@@ -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>`;
|
||||
};
|
||||
+13
@@ -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>`;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const buildUnsubscribeTextFooter = (httpsUrl: string): string =>
|
||||
`\n\n--\nUnsubscribe: ${httpsUrl}`;
|
||||
+17
@@ -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}`,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
export const escapeHtml = (value: string): string =>
|
||||
value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
|
||||
+43
@@ -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,
|
||||
|
||||
-29
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
-9
@@ -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',
|
||||
}
|
||||
-39
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
-61
@@ -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);
|
||||
}
|
||||
}
|
||||
-25
@@ -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 {}
|
||||
-84
@@ -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();
|
||||
});
|
||||
});
|
||||
-44
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
-62
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-58
@@ -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;
|
||||
}
|
||||
}
|
||||
-59
@@ -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);
|
||||
}
|
||||
}
|
||||
-58
@@ -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);
|
||||
}
|
||||
}
|
||||
-39
@@ -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}`);
|
||||
}
|
||||
}
|
||||
-15
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import { type SESMessage } from 'aws-lambda';
|
||||
|
||||
export type SesInboundNotification = SESMessage & {
|
||||
notificationType?: string;
|
||||
};
|
||||
-53
@@ -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();
|
||||
});
|
||||
});
|
||||
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
-29
@@ -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;
|
||||
};
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user