Ses outbound followup (#20610)
This pull request unifies outbound with inbound under the new feature and the new email groups feature. These are workspace level shared inboxes that are shared between all workspace members. outbound sending with SES works, we only listen for tenant status events, rest is managed by AWS PR refactors old code and webhook to be split for outbound and inbound for proper separation | Area | Change | |---|---| | AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant + identity + DKIM + MAIL FROM + configuration-set + EventBridge dest + contact list) and `AwsSesSendEmailService` (SendEmail). | | Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES → EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS → router → `SesOutboundSendingStateHandlerService` updates `emailing_domain.tenantStatus`. | | Inbound webhook | Refactored into `SesInboundWebhookRouterService` + `SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` + `SnsSubscriptionConfirmerService` across both routes. | | Global uniqueness | New migration + instance command: `emailing_domain.domain` is now globally unique (one tenant per domain across workspaces). | | Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` / `PAUSED`) + `EmailingDomainTenantStatusService`. | | Send-email mutation | New `sendEmailViaDomain` GraphQL mutation + DTOs. | | Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into `WorkspaceService.deleteWorkspace` — tears down SES tenant association + identity on workspace delete. | | Settings UI | Rewritten around reusable `SettingsTableListSection`. "Email Group" → "Email Handle" rename. New cells for status/source/forwarding. Outbound domains surfaced on workspace settings page. | ### Env vars (new) All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional: - `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider` + driver factory - `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory - `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound webhook routers, comma-separated list of accepted SNS topic ARNs (verified via `sns-payload-validator`) ### Migrations - `1778862608620-add-emailing-domain-tenant-status` (fast) — adds `tenantStatus` column. - `1778865501791-unique-emailing-domain-globally` (slow, idempotent) — enforces global uniqueness on `domain`. - Instance commands bumped to `2.5`. ### Infra dependency Two coupled twenty-infra PRs: - `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket policy + KMS grant + `email_group_*` outputs. - `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM policy + outbound `webhook_url` subscription. **Based on `ses-inbound-email`.** Merge order: inbound first, then outbound. Outbound PR's chart edit owns the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs). Features lives under `/settings/general` <img width="1496" height="845" alt="SCR-20260519-ofhi-2" src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d" /> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
|
||||
|
||||
@Injectable()
|
||||
export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
|
||||
constructor(
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly emailingDomainService: EmailingDomainService,
|
||||
) {}
|
||||
|
||||
async sendMessage(
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<SendMessageResult> {
|
||||
const emailingDomain = await this.resolveEmailingDomain(connectedAccount);
|
||||
|
||||
if (emailingDomain.status !== EmailingDomainStatus.VERIFIED) {
|
||||
throw new MessageChannelException(
|
||||
`Cannot send from ${connectedAccount.handle}: domain ${emailingDomain.domain} is not verified for outbound (status: ${emailingDomain.status}).`,
|
||||
MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.emailingDomainService.sendEmail(
|
||||
connectedAccount.workspaceId,
|
||||
emailingDomain.id,
|
||||
{
|
||||
to: this.toRecipientArray(sendMessageInput.to),
|
||||
cc: this.toRecipientArray(sendMessageInput.cc),
|
||||
bcc: this.toRecipientArray(sendMessageInput.bcc),
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: isNonEmptyString(sendMessageInput.html)
|
||||
? sendMessageInput.html
|
||||
: undefined,
|
||||
from: connectedAccount.handle,
|
||||
replyTo: [connectedAccount.handle],
|
||||
attachments: sendMessageInput.attachments,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
headerMessageId: result.messageId,
|
||||
messageExternalId: result.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
async createDraft(): Promise<void> {
|
||||
throw new MessageChannelException(
|
||||
'Email handle channels do not support drafts.',
|
||||
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveEmailingDomain(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<EmailingDomainEntity> {
|
||||
const handleDomain = connectedAccount.handle.split('@')[1];
|
||||
|
||||
if (!isNonEmptyString(handleDomain)) {
|
||||
throw new MessageChannelException(
|
||||
`Email group ${connectedAccount.handle} has no domain.`,
|
||||
MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const emailingDomain = await this.emailingDomainRepository.findOne(
|
||||
connectedAccount.workspaceId,
|
||||
{
|
||||
where: { domain: handleDomain },
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(emailingDomain)) {
|
||||
throw new MessageChannelException(
|
||||
`No outbound domain configured for ${handleDomain}. Verify it under Outbound Domains to send from ${connectedAccount.handle}.`,
|
||||
MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
return emailingDomain;
|
||||
}
|
||||
|
||||
private toRecipientArray(value: string | string[] | undefined): string[] {
|
||||
if (!isDefined(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -1,12 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
|
||||
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
|
||||
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
|
||||
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||
import { EmailGroupMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service';
|
||||
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
|
||||
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
|
||||
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
|
||||
@@ -20,15 +24,22 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
|
||||
MessagingIMAPDriverModule,
|
||||
MessagingSmtpDriverModule,
|
||||
MessagingImportManagerModule,
|
||||
TypeOrmModule.forFeature([MessageChannelEntity, MessageFolderEntity]),
|
||||
EmailingDomainModule,
|
||||
TypeOrmModule.forFeature([
|
||||
MessageChannelEntity,
|
||||
MessageFolderEntity,
|
||||
EmailingDomainEntity,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
GmailMessageOutboundService,
|
||||
MicrosoftMessageOutboundService,
|
||||
ImapSmtpMessageOutboundService,
|
||||
EmailGroupMessageOutboundService,
|
||||
MessagingMessageOutboundService,
|
||||
SendEmailService,
|
||||
SentMessagePersistenceService,
|
||||
provideWorkspaceScopedRepository(EmailingDomainEntity),
|
||||
],
|
||||
exports: [
|
||||
MessagingMessageOutboundService,
|
||||
|
||||
+5
-5
@@ -4,6 +4,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { EmailGroupMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service';
|
||||
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
|
||||
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
|
||||
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
|
||||
@@ -16,6 +17,7 @@ export class MessagingMessageOutboundService {
|
||||
private readonly gmailMessageOutboundService: GmailMessageOutboundService,
|
||||
private readonly microsoftMessageOutboundService: MicrosoftMessageOutboundService,
|
||||
private readonly imapSmtpMessageOutboundService: ImapSmtpMessageOutboundService,
|
||||
private readonly emailGroupMessageOutboundService: EmailGroupMessageOutboundService,
|
||||
) {}
|
||||
|
||||
public async sendMessage(
|
||||
@@ -39,11 +41,9 @@ export class MessagingMessageOutboundService {
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
// Email group channels are inbound-only: replies should go through
|
||||
// the user's own Gmail/Outlook/IMAP account to avoid masking the
|
||||
// sender.
|
||||
throw new Error(
|
||||
'Email group channels are inbound-only; reply using your personal account.',
|
||||
return this.emailGroupMessageOutboundService.sendMessage(
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
|
||||
Reference in New Issue
Block a user