From c5606212f2e50f7ff3f4dc172fb727772b91d41c Mon Sep 17 00:00:00 2001
From: neo773 <62795688+neo773@users.noreply.github.com>
Date: Wed, 27 May 2026 23:08:44 +0530
Subject: [PATCH] Ses outbound followup (#20610)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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`
---------
Co-authored-by: Félix Malfait
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait
---
.../src/metadata/generated/schema.graphql | 84 +-
.../src/metadata/generated/schema.ts | 188 +--
.../src/metadata/generated/types.ts | 1241 +++++++++--------
.../src/generated-admin/graphql.ts | 1 -
.../src/generated-metadata/graphql.ts | 24 +-
.../SettingsAccountsNewEmailGroupChannel.tsx | 10 +-
.../components/SettingsDnsRecordsTable.tsx | 8 +-
.../components/SettingsTableListSection.tsx | 114 ++
.../SettingsEmailingDomainNameCell.tsx | 27 +
.../SettingsEmailingDomainRowDropdownMenu.tsx | 83 --
.../SettingsEmailingDomainStatusCell.tsx | 17 +
...tingsWorkspaceEmailGroupForwardingCell.tsx | 23 +
.../SettingsWorkspaceEmailGroupSection.tsx | 146 +-
.../SettingsWorkspaceEmailGroupSourceCell.tsx | 31 +
...ettingsWorkspaceEmailingDomainsSection.tsx | 48 +
.../src/pages/settings/SettingsWorkspace.tsx | 5 +
.../tabs/SettingsApplicationsDeveloperTab.tsx | 14 -
.../SettingsEmailingDomainDetail.tsx | 74 +-
.../SettingsEmailingDomains.tsx | 82 --
.../SettingsNewEmailingDomain.tsx | 8 +-
...ttingsWorkspaceEmailGroupChannelDetail.tsx | 36 +-
...ain-tenant-status-and-global-uniqueness.ts | 39 +
.../instance-commands.constant.ts | 6 +-
.../aws-ses-event-bus-name.constant.ts | 1 +
.../aws-ses-mail-from-subdomain.constant.ts | 1 +
.../aws-ses-marketing-topic-name.constant.ts | 1 +
.../aws-ses-resource-name-prefix.constant.ts | 1 +
.../providers/aws-ses-client.provider.ts | 4 +-
.../aws-ses-register-domain.service.spec.ts | 132 ++
.../aws-ses-send-email.service.spec.ts | 113 ++
.../services/aws-ses-driver.service.ts | 150 +-
.../aws-ses-register-domain.service.ts | 140 ++
.../services/aws-ses-send-email.service.ts | 108 ++
.../drivers/emailing-domain-driver.factory.ts | 8 +-
.../emailing-domain-driver.exception.ts | 3 +
.../interfaces/driver-config.interface.ts | 2 +-
.../emailing-domain-driver.interface.ts | 25 +-
.../types/emailing-domain-driver.type.ts | 3 +
...main.ts => emailing-domain-status.type.ts} | 4 -
.../emailing-domain-tenant-status.type.ts | 5 +
.../drivers/types/send-email.ts | 26 +
.../dtos/emailing-domain.dto.ts | 6 +-
.../dtos/send-email-via-domain-output.dto.ts | 7 +
.../dtos/send-email-via-domain.input.ts | 59 +
.../emailing-domain/emailing-domain.entity.ts | 20 +-
.../emailing-domain/emailing-domain.module.ts | 12 +-
.../emailing-domain.resolver.ts | 30 +-
.../emailing-domain-workspace-cleanup.job.ts | 28 +
.../__tests__/emailing-domain.service.spec.ts | 97 ++
.../emailing-domain-tenant-status.service.ts | 35 +
.../services/emailing-domain.service.ts | 228 ++-
.../core-modules/message-queue/jobs.module.ts | 2 +
.../messaging-webhook-api-exception.filter.ts | 29 +
.../messaging-webhook-exception-code.enum.ts | 9 +
.../messaging-webhook.exception.ts | 39 +
.../messaging-webhooks.controller.ts | 68 +-
.../messaging-webhooks.module.ts | 18 +-
...ound-sending-state-handler.service.spec.ts | 84 ++
.../messaging-webhook-dispatcher.service.ts | 99 --
.../ses-inbound-mail-handler.service.ts | 43 +
.../ses-inbound-webhook-router.service.ts | 62 +
...-outbound-sending-state-handler.service.ts | 58 +
.../ses-outbound-webhook-router.service.ts | 59 +
.../sns-signature-verifier.service.ts | 14 +-
.../sns-subscription-confirmer.service.ts | 39 +
.../ses-event-bridge-notification.type.ts | 15 +
...-id-from-aws-ses-resource-arn.util.spec.ts | 53 +
...ging-webhook-exception-status-code.util.ts | 24 +
...space-id-from-aws-ses-resource-arn.util.ts | 29 +
.../workspace/services/workspace.service.ts | 9 +
.../connected-account-metadata.service.ts | 18 +-
.../message-channel-metadata.service.ts | 22 +-
.../workspace-entity-manager.spec.ts | 2 -
.../core/utils/seed-feature-flags.util.ts | 5 -
.../email-group-message-outbound.service.ts | 108 ++
.../messaging-send-manager.module.ts | 13 +-
.../messaging-message-outbound.service.ts | 10 +-
.../suites/mcp.controller.integration-spec.ts | 2 +-
.../twenty-shared/src/types/FeatureFlagKey.ts | 1 -
.../twenty-shared/src/types/SettingsPath.ts | 4 +-
80 files changed, 3155 insertions(+), 1341 deletions(-)
create mode 100644 packages/twenty-front/src/modules/settings/components/SettingsTableListSection.tsx
create mode 100644 packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainNameCell.tsx
delete mode 100644 packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu.tsx
create mode 100644 packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainStatusCell.tsx
create mode 100644 packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell.tsx
create mode 100644 packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell.tsx
create mode 100644 packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection.tsx
delete mode 100644 packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomains.tsx
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000020000-emailing-domain-tenant-status-and-global-uniqueness.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-register-domain.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-send-email.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type.ts
rename packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/{emailing-domain.ts => emailing-domain-status.type.ts} (70%)
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/send-email.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/services/__tests__/emailing-domain.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook.exception.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/__tests__/ses-outbound-sending-state-handler.service.spec.ts
delete mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/__tests__/parse-workspace-id-from-aws-ses-resource-arn.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/get-messaging-webhook-exception-status-code.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util.ts
create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service.ts
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index 5d6cb3f376..9fb15c446c 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -1446,6 +1446,39 @@ type Analytics {
success: Boolean!
}
+type VerificationRecord {
+ type: String!
+ key: String!
+ value: String!
+ priority: Float
+}
+
+type EmailingDomain {
+ id: UUID!
+ createdAt: DateTime!
+ updatedAt: DateTime!
+ domain: String!
+ driver: EmailingDomainDriver!
+ status: EmailingDomainStatus!
+ verificationRecords: [VerificationRecord!]
+ verifiedAt: DateTime
+}
+
+enum EmailingDomainDriver {
+ AWS_SES
+}
+
+enum EmailingDomainStatus {
+ PENDING
+ VERIFIED
+ FAILED
+ TEMPORARY_FAILURE
+}
+
+type SendEmailViaDomainOutput {
+ messageId: String!
+}
+
type ApprovedAccessDomain {
id: UUID!
domain: String!
@@ -1756,7 +1789,6 @@ enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_PUBLIC_DOMAIN_ENABLED
- IS_EMAILING_DOMAIN_ENABLED
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
@@ -2373,35 +2405,6 @@ type PublicDomain {
createdAt: DateTime!
}
-type VerificationRecord {
- type: String!
- key: String!
- value: String!
- priority: Float
-}
-
-type EmailingDomain {
- id: UUID!
- createdAt: DateTime!
- updatedAt: DateTime!
- domain: String!
- driver: EmailingDomainDriver!
- status: EmailingDomainStatus!
- verificationRecords: [VerificationRecord!]
- verifiedAt: DateTime
-}
-
-enum EmailingDomainDriver {
- AWS_SES
-}
-
-enum EmailingDomainStatus {
- PENDING
- VERIFIED
- FAILED
- TEMPORARY_FAILURE
-}
-
type AutocompleteResult {
text: String!
placeId: String!
@@ -2946,6 +2949,7 @@ type Query {
getPageLayoutTab(id: String!): PageLayoutTab!
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
getPageLayout(id: String!): PageLayout
+ getEmailingDomains: [EmailingDomain!]!
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
@@ -3040,7 +3044,6 @@ type Query {
getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
findManyPublicDomains: [PublicDomain!]!
- getEmailingDomains: [EmailingDomain!]!
findManyMarketplaceApps: [MarketplaceApp!]!
findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail!
}
@@ -3195,6 +3198,10 @@ type Mutation {
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
+ createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
+ deleteEmailingDomain(id: String!): Boolean!
+ verifyEmailingDomain(id: String!): EmailingDomain!
+ sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3315,9 +3322,6 @@ type Mutation {
updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
deletePublicDomain(domain: String!): Boolean!
checkPublicDomainValidRecords(domain: String!): DomainValidRecords
- createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
- deleteEmailingDomain(id: String!): Boolean!
- verifyEmailingDomain(id: String!): EmailingDomain!
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean! @deprecated(reason: "Use installApplication instead")
installApplication(universalIdentifier: String!, version: String): Application!
@@ -3761,6 +3765,18 @@ input GridPositionInput {
columnSpan: Float!
}
+input SendEmailViaDomainInput {
+ emailingDomainId: String!
+ to: [String!]!
+ cc: [String!]
+ bcc: [String!]
+ subject: String!
+ text: String!
+ html: String
+ from: String!
+ replyTo: [String!]
+}
+
input CreatePageLayoutWidgetInput {
pageLayoutTabId: UUID!
title: String!
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 51c3489d32..93656293cd 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -1107,6 +1107,35 @@ export interface Analytics {
__typename: 'Analytics'
}
+export interface VerificationRecord {
+ type: Scalars['String']
+ key: Scalars['String']
+ value: Scalars['String']
+ priority?: Scalars['Float']
+ __typename: 'VerificationRecord'
+}
+
+export interface EmailingDomain {
+ id: Scalars['UUID']
+ createdAt: Scalars['DateTime']
+ updatedAt: Scalars['DateTime']
+ domain: Scalars['String']
+ driver: EmailingDomainDriver
+ status: EmailingDomainStatus
+ verificationRecords?: VerificationRecord[]
+ verifiedAt?: Scalars['DateTime']
+ __typename: 'EmailingDomain'
+}
+
+export type EmailingDomainDriver = 'AWS_SES'
+
+export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
+
+export interface SendEmailViaDomainOutput {
+ messageId: Scalars['String']
+ __typename: 'SendEmailViaDomainOutput'
+}
+
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
@@ -1395,7 +1424,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
-export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
+export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -2051,30 +2080,6 @@ export interface PublicDomain {
__typename: 'PublicDomain'
}
-export interface VerificationRecord {
- type: Scalars['String']
- key: Scalars['String']
- value: Scalars['String']
- priority?: Scalars['Float']
- __typename: 'VerificationRecord'
-}
-
-export interface EmailingDomain {
- id: Scalars['UUID']
- createdAt: Scalars['DateTime']
- updatedAt: Scalars['DateTime']
- domain: Scalars['String']
- driver: EmailingDomainDriver
- status: EmailingDomainStatus
- verificationRecords?: VerificationRecord[]
- verifiedAt?: Scalars['DateTime']
- __typename: 'EmailingDomain'
-}
-
-export type EmailingDomainDriver = 'AWS_SES'
-
-export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
-
export interface AutocompleteResult {
text: Scalars['String']
placeId: Scalars['String']
@@ -2572,6 +2577,7 @@ export interface Query {
getPageLayoutTab: PageLayoutTab
getPageLayouts: PageLayout[]
getPageLayout?: PageLayout
+ getEmailingDomains: EmailingDomain[]
applicationConnectionProviders: ApplicationConnectionProvider[]
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
@@ -2639,7 +2645,6 @@ export interface Query {
getAddressDetails: PlaceDetailsResult
getUsageAnalytics: UsageAnalytics
findManyPublicDomains: PublicDomain[]
- getEmailingDomains: EmailingDomain[]
findManyMarketplaceApps: MarketplaceApp[]
findMarketplaceAppDetail: MarketplaceAppDetail
__typename: 'Query'
@@ -2727,6 +2732,10 @@ export interface Mutation {
resetPageLayoutToDefault: PageLayout
resetPageLayoutWidgetToDefault: PageLayoutWidget
resetPageLayoutTabToDefault: PageLayoutTab
+ createEmailingDomain: EmailingDomain
+ deleteEmailingDomain: Scalars['Boolean']
+ verifyEmailingDomain: EmailingDomain
+ sendEmailViaEmailingDomain: SendEmailViaDomainOutput
updateOneApplicationVariable: Scalars['Boolean']
createPageLayoutWidget: PageLayoutWidget
updatePageLayoutWidget: PageLayoutWidget
@@ -2847,9 +2856,6 @@ export interface Mutation {
updatePublicDomain: PublicDomain
deletePublicDomain: Scalars['Boolean']
checkPublicDomainValidRecords?: DomainValidRecords
- createEmailingDomain: EmailingDomain
- deleteEmailingDomain: Scalars['Boolean']
- verifyEmailingDomain: EmailingDomain
createOneAppToken: AppToken
/** @deprecated Use installApplication instead */
installMarketplaceApp: Scalars['Boolean']
@@ -4042,6 +4048,34 @@ export interface AnalyticsGenqlSelection{
__scalar?: boolean | number
}
+export interface VerificationRecordGenqlSelection{
+ type?: boolean | number
+ key?: boolean | number
+ value?: boolean | number
+ priority?: boolean | number
+ __typename?: boolean | number
+ __scalar?: boolean | number
+}
+
+export interface EmailingDomainGenqlSelection{
+ id?: boolean | number
+ createdAt?: boolean | number
+ updatedAt?: boolean | number
+ domain?: boolean | number
+ driver?: boolean | number
+ status?: boolean | number
+ verificationRecords?: VerificationRecordGenqlSelection
+ verifiedAt?: boolean | number
+ __typename?: boolean | number
+ __scalar?: boolean | number
+}
+
+export interface SendEmailViaDomainOutputGenqlSelection{
+ messageId?: boolean | number
+ __typename?: boolean | number
+ __scalar?: boolean | number
+}
+
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
@@ -5053,28 +5087,6 @@ export interface PublicDomainGenqlSelection{
__scalar?: boolean | number
}
-export interface VerificationRecordGenqlSelection{
- type?: boolean | number
- key?: boolean | number
- value?: boolean | number
- priority?: boolean | number
- __typename?: boolean | number
- __scalar?: boolean | number
-}
-
-export interface EmailingDomainGenqlSelection{
- id?: boolean | number
- createdAt?: boolean | number
- updatedAt?: boolean | number
- domain?: boolean | number
- driver?: boolean | number
- status?: boolean | number
- verificationRecords?: VerificationRecordGenqlSelection
- verifiedAt?: boolean | number
- __typename?: boolean | number
- __scalar?: boolean | number
-}
-
export interface AutocompleteResultGenqlSelection{
text?: boolean | number
placeId?: boolean | number
@@ -5590,6 +5602,7 @@ export interface QueryGenqlSelection{
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
+ getEmailingDomains?: EmailingDomainGenqlSelection
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
@@ -5675,7 +5688,6 @@ export interface QueryGenqlSelection{
getAddressDetails?: (PlaceDetailsResultGenqlSelection & { __args: {placeId: Scalars['String'], token: Scalars['String']} })
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
findManyPublicDomains?: PublicDomainGenqlSelection
- getEmailingDomains?: EmailingDomainGenqlSelection
findManyMarketplaceApps?: MarketplaceAppGenqlSelection
findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
__typename?: boolean | number
@@ -5784,6 +5796,10 @@ export interface MutationGenqlSelection{
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
+ createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
+ deleteEmailingDomain?: { __args: {id: Scalars['String']} }
+ verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
+ sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
@@ -5904,9 +5920,6 @@ export interface MutationGenqlSelection{
updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
deletePublicDomain?: { __args: {domain: Scalars['String']} }
checkPublicDomainValidRecords?: (DomainValidRecordsGenqlSelection & { __args: {domain: Scalars['String']} })
- createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
- deleteEmailingDomain?: { __args: {id: Scalars['String']} }
- verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
/** @deprecated Use installApplication instead */
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
@@ -6084,6 +6097,8 @@ export interface UpdatePageLayoutWidgetWithIdInput {id: Scalars['UUID'],pageLayo
export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float'],rowSpan: Scalars['Float'],columnSpan: Scalars['Float']}
+export interface SendEmailViaDomainInput {emailingDomainId: Scalars['String'],to: Scalars['String'][],cc?: (Scalars['String'][] | null),bcc?: (Scalars['String'][] | null),subject: Scalars['String'],text: Scalars['String'],html?: (Scalars['String'] | null),from: Scalars['String'],replyTo?: (Scalars['String'][] | null)}
+
export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],title: Scalars['String'],type: WidgetType,objectMetadataId?: (Scalars['UUID'] | null),gridPosition: GridPositionInput,position?: (Scalars['JSON'] | null),configuration: Scalars['JSON']}
export interface UpdatePageLayoutWidgetInput {pageLayoutTabId?: (Scalars['UUID'] | null),title?: (Scalars['String'] | null),type?: (WidgetType | null),objectMetadataId?: (Scalars['UUID'] | null),gridPosition?: (GridPositionInput | null),position?: (Scalars['JSON'] | null),configuration?: (Scalars['JSON'] | null),conditionalDisplay?: (Scalars['JSON'] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null)}
@@ -6957,6 +6972,30 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
+ const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
+ export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
+ if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
+ return VerificationRecord_possibleTypes.includes(obj.__typename)
+ }
+
+
+
+ const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
+ export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
+ if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
+ return EmailingDomain_possibleTypes.includes(obj.__typename)
+ }
+
+
+
+ const SendEmailViaDomainOutput_possibleTypes: string[] = ['SendEmailViaDomainOutput']
+ export const isSendEmailViaDomainOutput = (obj?: { __typename?: any } | null): obj is SendEmailViaDomainOutput => {
+ if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailViaDomainOutput"')
+ return SendEmailViaDomainOutput_possibleTypes.includes(obj.__typename)
+ }
+
+
+
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
@@ -7861,22 +7900,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
- const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
- export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
- if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
- return VerificationRecord_possibleTypes.includes(obj.__typename)
- }
-
-
-
- const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
- export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
- if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
- return EmailingDomain_possibleTypes.includes(obj.__typename)
- }
-
-
-
const AutocompleteResult_possibleTypes: string[] = ['AutocompleteResult']
export const isAutocompleteResult = (obj?: { __typename?: any } | null): obj is AutocompleteResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAutocompleteResult"')
@@ -8640,6 +8663,17 @@ export const enumPageLayoutType = {
STANDALONE_PAGE: 'STANDALONE_PAGE' as const
}
+export const enumEmailingDomainDriver = {
+ AWS_SES: 'AWS_SES' as const
+}
+
+export const enumEmailingDomainStatus = {
+ PENDING: 'PENDING' as const,
+ VERIFIED: 'VERIFIED' as const,
+ FAILED: 'FAILED' as const,
+ TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
+}
+
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8706,7 +8740,6 @@ export const enumFeatureFlagKey = {
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
- IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
@@ -8753,17 +8786,6 @@ export const enumBillingEntitlementKey = {
AUDIT_LOGS: 'AUDIT_LOGS' as const
}
-export const enumEmailingDomainDriver = {
- AWS_SES: 'AWS_SES' as const
-}
-
-export const enumEmailingDomainStatus = {
- PENDING: 'PENDING' as const,
- VERIFIED: 'VERIFIED' as const,
- FAILED: 'FAILED' as const,
- TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
-}
-
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index 0762494939..0d58eb165d 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -41,45 +41,45 @@ export default {
104,
110,
124,
- 135,
- 136,
- 137,
- 139,
- 147,
- 158,
- 161,
+ 132,
+ 133,
+ 140,
+ 141,
+ 142,
+ 144,
+ 152,
163,
- 167,
- 169,
+ 166,
+ 168,
+ 172,
174,
- 175,
- 182,
- 185,
- 188,
- 201,
- 226,
- 262,
- 263,
- 293,
- 302,
+ 179,
+ 180,
+ 187,
+ 190,
+ 193,
+ 206,
+ 231,
+ 294,
303,
304,
305,
- 307,
+ 306,
308,
309,
310,
311,
312,
313,
- 316,
- 318,
- 327,
- 334,
- 341,
- 377,
- 458,
- 470
+ 314,
+ 317,
+ 319,
+ 328,
+ 335,
+ 342,
+ 378,
+ 460,
+ 472
],
"types": {
"BillingProductDTO": {
@@ -93,13 +93,13 @@ export default {
1
],
"metadata": [
- 134
+ 139
],
"on_BillingLicensedProduct": [
- 143
+ 148
],
"on_BillingMeteredProduct": [
- 144
+ 149
],
"__typename": [
1
@@ -949,10 +949,10 @@ export default {
3
],
"relation": [
- 200
+ 205
],
"morphRelations": [
- 200
+ 205
],
"object": [
55
@@ -1014,7 +1014,7 @@ export default {
45
],
"objectMetadata": [
- 208,
+ 213,
{
"paging": [
48,
@@ -1027,7 +1027,7 @@ export default {
}
],
"indexFieldMetadatas": [
- 206,
+ 211,
{
"paging": [
48,
@@ -1272,7 +1272,7 @@ export default {
46
],
"fields": [
- 213,
+ 218,
{
"paging": [
48,
@@ -1285,7 +1285,7 @@ export default {
}
],
"indexMetadatas": [
- 211,
+ 216,
{
"paging": [
48,
@@ -1878,19 +1878,19 @@ export default {
58
],
"featureFlags": [
- 168
+ 173
],
"billingSubscriptions": [
- 146
+ 151
],
"installedApplications": [
58
],
"currentBillingSubscription": [
- 146
+ 151
],
"billingEntitlements": [
- 225
+ 230
],
"hasValidEnterpriseKey": [
6
@@ -1902,7 +1902,7 @@ export default {
6
],
"workspaceUrls": [
- 170
+ 175
],
"workspaceCustomApplicationId": [
1
@@ -1991,7 +1991,7 @@ export default {
20
],
"deletedWorkspaceMembers": [
- 224
+ 229
],
"hasPassword": [
6
@@ -2003,7 +2003,7 @@ export default {
17
],
"availableWorkspaces": [
- 223
+ 228
],
"__typename": [
1
@@ -2885,6 +2885,62 @@ export default {
1
]
},
+ "VerificationRecord": {
+ "type": [
+ 1
+ ],
+ "key": [
+ 1
+ ],
+ "value": [
+ 1
+ ],
+ "priority": [
+ 11
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "EmailingDomain": {
+ "id": [
+ 3
+ ],
+ "createdAt": [
+ 4
+ ],
+ "updatedAt": [
+ 4
+ ],
+ "domain": [
+ 1
+ ],
+ "driver": [
+ 132
+ ],
+ "status": [
+ 133
+ ],
+ "verificationRecords": [
+ 130
+ ],
+ "verifiedAt": [
+ 4
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "EmailingDomainDriver": {},
+ "EmailingDomainStatus": {},
+ "SendEmailViaDomainOutput": {
+ "messageId": [
+ 1
+ ],
+ "__typename": [
+ 1
+ ]
+ },
"ApprovedAccessDomain": {
"id": [
3
@@ -2941,7 +2997,7 @@ export default {
11
],
"items": [
- 132
+ 137
],
"__typename": [
1
@@ -2949,13 +3005,13 @@ export default {
},
"BillingProductMetadata": {
"planKey": [
- 135
+ 140
],
"priceUsageBased": [
- 136
+ 141
],
"productKey": [
- 137
+ 142
],
"__typename": [
1
@@ -2966,7 +3022,7 @@ export default {
"BillingProductKey": {},
"BillingPriceLicensed": {
"recurringInterval": [
- 139
+ 144
],
"unitAmount": [
11
@@ -2975,7 +3031,7 @@ export default {
1
],
"priceUsageType": [
- 136
+ 141
],
"creditAmount": [
11
@@ -3001,16 +3057,16 @@ export default {
},
"BillingPriceMetered": {
"tiers": [
- 140
+ 145
],
"recurringInterval": [
- 139
+ 144
],
"stripePriceId": [
1
],
"priceUsageType": [
- 136
+ 141
],
"__typename": [
1
@@ -3027,7 +3083,7 @@ export default {
1
],
"metadata": [
- 134
+ 139
],
"__typename": [
1
@@ -3044,10 +3100,10 @@ export default {
1
],
"metadata": [
- 134
+ 139
],
"prices": [
- 138
+ 143
],
"__typename": [
1
@@ -3064,10 +3120,10 @@ export default {
1
],
"metadata": [
- 134
+ 139
],
"prices": [
- 141
+ 146
],
"__typename": [
1
@@ -3098,13 +3154,13 @@ export default {
3
],
"status": [
- 147
+ 152
],
"interval": [
- 139
+ 144
],
"billingSubscriptionItems": [
- 145
+ 150
],
"currentPeriodEnd": [
4
@@ -3113,7 +3169,7 @@ export default {
15
],
"phases": [
- 133
+ 138
],
"__typename": [
1
@@ -3122,7 +3178,7 @@ export default {
"SubscriptionStatus": {},
"BillingEndTrialPeriod": {
"status": [
- 147
+ 152
],
"hasPaymentMethod": [
6
@@ -3136,7 +3192,7 @@ export default {
},
"BillingResourceCreditUsage": {
"productKey": [
- 137
+ 142
],
"periodStart": [
4
@@ -3165,16 +3221,16 @@ export default {
},
"BillingPlan": {
"planKey": [
- 135
+ 140
],
"baseProducts": [
- 143
+ 148
],
"resourceCreditProducts": [
- 143
+ 148
],
"meteredProducts": [
- 144
+ 149
],
"__typename": [
1
@@ -3190,10 +3246,10 @@ export default {
},
"BillingUpdate": {
"currentBillingSubscription": [
- 146
+ 151
],
"billingSubscriptions": [
- 146
+ 151
],
"__typename": [
1
@@ -3232,7 +3288,7 @@ export default {
1
],
"result": [
- 154
+ 159
],
"__typename": [
1
@@ -3269,7 +3325,7 @@ export default {
3
],
"type": [
- 158
+ 163
],
"name": [
1
@@ -3302,7 +3358,7 @@ export default {
4
],
"targetRecordIdentifier": [
- 156
+ 161
],
"__typename": [
1
@@ -3328,7 +3384,7 @@ export default {
},
"MetadataEvent": {
"type": [
- 161
+ 166
],
"metadataName": [
1
@@ -3337,7 +3393,7 @@ export default {
1
],
"properties": [
- 159
+ 164
],
"updatedCollectionHash": [
1
@@ -3349,7 +3405,7 @@ export default {
"MetadataEventAction": {},
"ObjectRecordEvent": {
"action": [
- 163
+ 168
],
"objectNameSingular": [
1
@@ -3364,7 +3420,7 @@ export default {
1
],
"properties": [
- 159
+ 164
],
"__typename": [
1
@@ -3376,7 +3432,7 @@ export default {
1
],
"objectRecordEvent": [
- 162
+ 167
],
"__typename": [
1
@@ -3387,10 +3443,10 @@ export default {
1
],
"objectRecordEventsWithQueryIds": [
- 164
+ 169
],
"metadataEvents": [
- 160
+ 165
],
"__typename": [
1
@@ -3407,7 +3463,7 @@ export default {
11
],
"status": [
- 167
+ 172
],
"error": [
15
@@ -3419,7 +3475,7 @@ export default {
"LogicFunctionExecutionStatus": {},
"FeatureFlag": {
"key": [
- 169
+ 174
],
"value": [
6
@@ -3491,10 +3547,10 @@ export default {
1
],
"type": [
- 174
+ 179
],
"status": [
- 175
+ 180
],
"issuer": [
1
@@ -3507,7 +3563,7 @@ export default {
"SSOIdentityProviderStatus": {},
"AuthProviders": {
"sso": [
- 173
+ 178
],
"google": [
6
@@ -3544,10 +3600,10 @@ export default {
3
],
"authProviders": [
- 176
+ 181
],
"authBypassProviders": [
- 177
+ 182
],
"logo": [
1
@@ -3556,7 +3612,7 @@ export default {
1
],
"workspaceUrls": [
- 170
+ 175
],
"__typename": [
1
@@ -3595,7 +3651,7 @@ export default {
1
],
"modelFamily": [
- 182
+ 187
],
"modelFamilyLabel": [
1
@@ -3610,7 +3666,7 @@ export default {
11
],
"nativeCapabilities": [
- 180
+ 185
],
"isDeprecated": [
6
@@ -3646,7 +3702,7 @@ export default {
1
],
"trialPeriods": [
- 172
+ 177
],
"__typename": [
1
@@ -3654,7 +3710,7 @@ export default {
},
"Support": {
"supportDriver": [
- 185
+ 190
],
"supportFrontChatId": [
1
@@ -3680,7 +3736,7 @@ export default {
},
"Captcha": {
"provider": [
- 188
+ 193
],
"siteKey": [
1
@@ -3714,10 +3770,10 @@ export default {
},
"PublicFeatureFlag": {
"key": [
- 169
+ 174
],
"metadata": [
- 190
+ 195
],
"__typename": [
1
@@ -3742,13 +3798,13 @@ export default {
1
],
"authProviders": [
- 176
+ 181
],
"billing": [
- 183
+ 188
],
"aiModels": [
- 181
+ 186
],
"signInPrefilled": [
6
@@ -3769,25 +3825,25 @@ export default {
6
],
"support": [
- 184
+ 189
],
"isAttachmentPreviewEnabled": [
6
],
"sentry": [
- 186
+ 191
],
"captcha": [
- 187
+ 192
],
"api": [
- 189
+ 194
],
"canManageFeatureFlags": [
6
],
"publicFeatureFlags": [
- 191
+ 196
],
"isMicrosoftMessagingEnabled": [
6
@@ -3826,7 +3882,7 @@ export default {
6
],
"maintenance": [
- 192
+ 197
],
"__typename": [
1
@@ -3865,7 +3921,7 @@ export default {
1
],
"versionDistribution": [
- 195
+ 200
],
"__typename": [
1
@@ -3912,7 +3968,7 @@ export default {
},
"Relation": {
"type": [
- 201
+ 206
],
"sourceObjectMetadata": [
55
@@ -3961,10 +4017,10 @@ export default {
},
"IndexConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 202
+ 207
],
"__typename": [
1
@@ -3983,10 +4039,10 @@ export default {
},
"IndexIndexFieldMetadatasConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 205
+ 210
],
"__typename": [
1
@@ -4005,10 +4061,10 @@ export default {
},
"IndexObjectMetadataConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 207
+ 212
],
"__typename": [
1
@@ -4027,10 +4083,10 @@ export default {
},
"ObjectConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 207
+ 212
],
"__typename": [
1
@@ -4038,10 +4094,10 @@ export default {
},
"ObjectIndexMetadatasConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 202
+ 207
],
"__typename": [
1
@@ -4060,10 +4116,10 @@ export default {
},
"ObjectFieldsConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 212
+ 217
],
"__typename": [
1
@@ -4071,10 +4127,10 @@ export default {
},
"FieldConnection": {
"pageInfo": [
- 203
+ 208
],
"edges": [
- 212
+ 217
],
"__typename": [
1
@@ -4101,7 +4157,7 @@ export default {
3
],
"type": [
- 174
+ 179
],
"issuer": [
1
@@ -4110,7 +4166,7 @@ export default {
1
],
"status": [
- 175
+ 180
],
"__typename": [
1
@@ -4129,7 +4185,7 @@ export default {
},
"FindAvailableSSOIDP": {
"type": [
- 174
+ 179
],
"id": [
3
@@ -4141,10 +4197,10 @@ export default {
1
],
"status": [
- 175
+ 180
],
"workspace": [
- 218
+ 223
],
"__typename": [
1
@@ -4155,7 +4211,7 @@ export default {
3
],
"type": [
- 174
+ 179
],
"issuer": [
1
@@ -4164,7 +4220,7 @@ export default {
1
],
"status": [
- 175
+ 180
],
"__typename": [
1
@@ -4172,7 +4228,7 @@ export default {
},
"SSOConnection": {
"type": [
- 174
+ 179
],
"id": [
3
@@ -4184,7 +4240,7 @@ export default {
1
],
"status": [
- 175
+ 180
],
"__typename": [
1
@@ -4207,13 +4263,13 @@ export default {
1
],
"workspaceUrls": [
- 170
+ 175
],
"logo": [
1
],
"sso": [
- 221
+ 226
],
"__typename": [
1
@@ -4221,10 +4277,10 @@ export default {
},
"AvailableWorkspaces": {
"availableWorkspacesForSignIn": [
- 222
+ 227
],
"availableWorkspacesForSignUp": [
- 222
+ 227
],
"__typename": [
1
@@ -4252,7 +4308,7 @@ export default {
},
"BillingEntitlement": {
"key": [
- 226
+ 231
],
"value": [
6
@@ -4290,7 +4346,7 @@ export default {
1
],
"records": [
- 227
+ 232
],
"__typename": [
1
@@ -4360,10 +4416,10 @@ export default {
},
"AvailableWorkspacesAndAccessTokens": {
"tokens": [
- 235
+ 240
],
"availableWorkspaces": [
- 223
+ 228
],
"__typename": [
1
@@ -4401,7 +4457,7 @@ export default {
},
"WorkspaceUrlsAndId": {
"workspaceUrls": [
- 170
+ 175
],
"id": [
3
@@ -4415,7 +4471,7 @@ export default {
32
],
"workspace": [
- 240
+ 245
],
"__typename": [
1
@@ -4448,7 +4504,7 @@ export default {
32
],
"workspaceUrls": [
- 170
+ 175
],
"__typename": [
1
@@ -4464,7 +4520,7 @@ export default {
},
"AuthTokens": {
"tokens": [
- 235
+ 240
],
"__typename": [
1
@@ -4505,7 +4561,7 @@ export default {
32
],
"workspace": [
- 240
+ 245
],
"__typename": [
1
@@ -4527,7 +4583,7 @@ export default {
1
],
"dailyUsage": [
- 251
+ 256
],
"__typename": [
1
@@ -4535,16 +4591,16 @@ export default {
},
"UsageAnalytics": {
"usageByUser": [
- 194
+ 199
],
"usageByOperationType": [
- 194
+ 199
],
"usageByModel": [
- 194
+ 199
],
"timeSeries": [
- 251
+ 256
],
"periodStart": [
4
@@ -4553,7 +4609,7 @@ export default {
4
],
"userDailyUsage": [
- 252
+ 257
],
"__typename": [
1
@@ -4679,54 +4735,6 @@ export default {
1
]
},
- "VerificationRecord": {
- "type": [
- 1
- ],
- "key": [
- 1
- ],
- "value": [
- 1
- ],
- "priority": [
- 11
- ],
- "__typename": [
- 1
- ]
- },
- "EmailingDomain": {
- "id": [
- 3
- ],
- "createdAt": [
- 4
- ],
- "updatedAt": [
- 4
- ],
- "domain": [
- 1
- ],
- "driver": [
- 262
- ],
- "status": [
- 263
- ],
- "verificationRecords": [
- 260
- ],
- "verifiedAt": [
- 4
- ],
- "__typename": [
- 1
- ]
- },
- "EmailingDomainDriver": {},
- "EmailingDomainStatus": {},
"AutocompleteResult": {
"text": [
1
@@ -4766,7 +4774,7 @@ export default {
1
],
"location": [
- 265
+ 266
],
"__typename": [
1
@@ -4791,13 +4799,13 @@ export default {
},
"PublicImapSmtpCaldavConnectionParameters": {
"IMAP": [
- 267
+ 268
],
"SMTP": [
- 267
+ 268
],
"CALDAV": [
- 267
+ 268
],
"__typename": [
1
@@ -4850,7 +4858,7 @@ export default {
4
],
"connectionParameters": [
- 268
+ 269
],
"__typename": [
1
@@ -4875,13 +4883,13 @@ export default {
},
"ImapSmtpCaldavPublicConnectionParameters": {
"IMAP": [
- 270
+ 271
],
"SMTP": [
- 270
+ 271
],
"CALDAV": [
- 270
+ 271
],
"__typename": [
1
@@ -4901,7 +4909,7 @@ export default {
3
],
"connectionParameters": [
- 271
+ 272
],
"__typename": [
1
@@ -5089,7 +5097,7 @@ export default {
1
],
"series": [
- 278
+ 279
],
"xAxisLabel": [
1
@@ -5138,7 +5146,7 @@ export default {
1
],
"data": [
- 280
+ 281
],
"__typename": [
1
@@ -5146,7 +5154,7 @@ export default {
},
"LineChartData": {
"series": [
- 281
+ 282
],
"xAxisLabel": [
1
@@ -5183,7 +5191,7 @@ export default {
},
"PieChartData": {
"data": [
- 283
+ 284
],
"showLegend": [
6
@@ -5277,13 +5285,13 @@ export default {
},
"EventLogQueryResult": {
"records": [
- 287
+ 288
],
"totalCount": [
21
],
"pageInfo": [
- 288
+ 289
],
"__typename": [
1
@@ -5347,7 +5355,7 @@ export default {
1
],
"parts": [
- 276
+ 277
],
"processedAt": [
4
@@ -5361,7 +5369,7 @@ export default {
},
"AgentChatThread": {
"id": [
- 293
+ 294
],
"title": [
1
@@ -5417,7 +5425,7 @@ export default {
},
"AiSystemPromptPreview": {
"sections": [
- 294
+ 295
],
"estimatedTokenCount": [
21
@@ -5493,10 +5501,10 @@ export default {
3
],
"evaluations": [
- 299
+ 300
],
"messages": [
- 291
+ 292
],
"createdAt": [
4
@@ -5513,19 +5521,19 @@ export default {
1
],
"syncStatus": [
- 302
- ],
- "syncStage": [
303
],
- "visibility": [
+ "syncStage": [
304
],
+ "visibility": [
+ 305
+ ],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
- 305
+ 306
],
"isSyncEnabled": [
6
@@ -5561,22 +5569,22 @@ export default {
3
],
"visibility": [
- 307
+ 308
],
"handle": [
1
],
"type": [
- 308
+ 309
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
- 309
+ 310
],
"messageFolderImportPolicy": [
- 310
+ 311
],
"excludeNonProfessionalEmails": [
6
@@ -5585,7 +5593,7 @@ export default {
6
],
"pendingGroupEmailsAction": [
- 311
+ 312
],
"isSyncEnabled": [
6
@@ -5594,10 +5602,10 @@ export default {
4
],
"syncStatus": [
- 312
+ 313
],
"syncStage": [
- 313
+ 314
],
"syncStageStartedAt": [
4
@@ -5618,7 +5626,7 @@ export default {
4
],
"connectedAccount": [
- 269
+ 270
],
"__typename": [
1
@@ -5633,7 +5641,7 @@ export default {
"MessageChannelSyncStage": {},
"CreateEmailGroupChannelOutput": {
"messageChannel": [
- 306
+ 307
],
"forwardingAddress": [
1
@@ -5662,7 +5670,7 @@ export default {
1
],
"pendingSyncAction": [
- 316
+ 317
],
"messageChannelId": [
3
@@ -5680,7 +5688,7 @@ export default {
"MessageFolderPendingSyncAction": {},
"CollectionHash": {
"collectionName": [
- 318
+ 319
],
"hash": [
1
@@ -5747,13 +5755,13 @@ export default {
},
"MinimalMetadata": {
"objectMetadataItems": [
- 319
- ],
- "views": [
320
],
+ "views": [
+ 321
+ ],
"collectionHashes": [
- 317
+ 318
],
"__typename": [
1
@@ -5761,10 +5769,10 @@ export default {
},
"Query": {
"navigationMenuItems": [
- 157
+ 162
],
"navigationMenuItem": [
- 157,
+ 162,
{
"id": [
3,
@@ -5906,13 +5914,13 @@ export default {
2,
{
"input": [
- 323,
+ 324,
"GetApiKeyInput!"
]
}
],
"billingPortalSession": [
- 151,
+ 156,
{
"returnUrlPath": [
1
@@ -5920,16 +5928,16 @@ export default {
}
],
"listPlans": [
- 150
+ 155
],
"getResourceCreditUsage": [
- 149
- ],
- "findWorkspaceInvitations": [
154
],
+ "findWorkspaceInvitations": [
+ 159
+ ],
"getApprovedAccessDomains": [
- 130
+ 135
],
"getPageLayoutTabs": [
122,
@@ -5969,6 +5977,9 @@ export default {
]
}
],
+ "getEmailingDomains": [
+ 131
+ ],
"applicationConnectionProviders": [
126,
{
@@ -6000,7 +6011,7 @@ export default {
41,
{
"input": [
- 324,
+ 325,
"LogicFunctionIdInput!"
]
}
@@ -6012,7 +6023,7 @@ export default {
15,
{
"input": [
- 324,
+ 325,
"LogicFunctionIdInput!"
]
}
@@ -6021,7 +6032,7 @@ export default {
1,
{
"input": [
- 324,
+ 325,
"LogicFunctionIdInput!"
]
}
@@ -6051,7 +6062,7 @@ export default {
}
],
"objectRecordCounts": [
- 209
+ 214
],
"object": [
55,
@@ -6063,7 +6074,7 @@ export default {
}
],
"objects": [
- 210,
+ 215,
{
"paging": [
48,
@@ -6085,7 +6096,7 @@ export default {
}
],
"indexMetadatas": [
- 204,
+ 209,
{
"paging": [
48,
@@ -6104,7 +6115,7 @@ export default {
25,
{
"input": [
- 325,
+ 326,
"AgentIdInput!"
]
}
@@ -6113,7 +6124,7 @@ export default {
29
],
"getToolIndex": [
- 275
+ 276
],
"getToolInputSchema": [
15,
@@ -6125,10 +6136,10 @@ export default {
}
],
"webhooks": [
- 274
+ 275
],
"webhook": [
- 274,
+ 275,
{
"id": [
3,
@@ -6146,7 +6157,7 @@ export default {
}
],
"fields": [
- 214,
+ 219,
{
"paging": [
48,
@@ -6176,7 +6187,7 @@ export default {
}
],
"myMessageFolders": [
- 315,
+ 316,
{
"messageChannelId": [
3
@@ -6184,7 +6195,7 @@ export default {
}
],
"myMessageChannels": [
- 306,
+ 307,
{
"connectedAccountId": [
3
@@ -6192,10 +6203,10 @@ export default {
}
],
"myConnectedAccounts": [
- 269
+ 270
],
"myCalendarChannels": [
- 301,
+ 302,
{
"connectedAccountId": [
3
@@ -6203,13 +6214,13 @@ export default {
}
],
"minimalMetadata": [
- 321
+ 322
],
"chatThreads": [
- 292
+ 293
],
"chatThread": [
- 292,
+ 293,
{
"id": [
3,
@@ -6218,7 +6229,7 @@ export default {
}
],
"chatMessages": [
- 291,
+ 292,
{
"threadId": [
3,
@@ -6227,7 +6238,7 @@ export default {
}
],
"chatStreamCatchupChunks": [
- 296,
+ 297,
{
"threadId": [
3,
@@ -6236,13 +6247,13 @@ export default {
}
],
"getAiSystemPromptPreview": [
- 295
+ 296
],
"skills": [
- 290
+ 291
],
"skill": [
- 290,
+ 291,
{
"id": [
3,
@@ -6251,7 +6262,7 @@ export default {
}
],
"agentTurns": [
- 300,
+ 301,
{
"agentId": [
3,
@@ -6260,7 +6271,7 @@ export default {
}
],
"checkUserExists": [
- 248,
+ 253,
{
"email": [
1,
@@ -6272,7 +6283,7 @@ export default {
}
],
"checkWorkspaceInviteHashIsValid": [
- 249,
+ 254,
{
"inviteHash": [
1,
@@ -6290,7 +6301,7 @@ export default {
}
],
"validatePasswordResetToken": [
- 243,
+ 248,
{
"passwordResetToken": [
1,
@@ -6299,7 +6310,7 @@ export default {
}
],
"findApplicationRegistrationByClientId": [
- 198,
+ 203,
{
"clientId": [
1,
@@ -6329,7 +6340,7 @@ export default {
}
],
"findApplicationRegistrationStats": [
- 196,
+ 201,
{
"id": [
1,
@@ -6338,7 +6349,7 @@ export default {
}
],
"findApplicationRegistrationVariables": [
- 171,
+ 176,
{
"applicationRegistrationId": [
1,
@@ -6362,7 +6373,7 @@ export default {
75
],
"getPublicWorkspaceDataByDomain": [
- 178,
+ 183,
{
"origin": [
1
@@ -6370,7 +6381,7 @@ export default {
}
],
"getPublicWorkspaceDataById": [
- 179,
+ 184,
{
"id": [
3,
@@ -6393,46 +6404,46 @@ export default {
}
],
"getSSOIdentityProviders": [
- 219
+ 224
],
"eventLogs": [
- 289,
+ 290,
{
"input": [
- 326,
+ 327,
"EventLogQueryInput!"
]
}
],
"pieChartData": [
- 284,
+ 285,
{
"input": [
- 330,
+ 331,
"PieChartDataInput!"
]
}
],
"lineChartData": [
- 282,
+ 283,
{
"input": [
- 331,
+ 332,
"LineChartDataInput!"
]
}
],
"barChartData": [
- 279,
+ 280,
{
"input": [
- 332,
+ 333,
"BarChartDataInput!"
]
}
],
"getConnectedImapSmtpCaldavAccount": [
- 272,
+ 273,
{
"id": [
3,
@@ -6441,7 +6452,7 @@ export default {
}
],
"getAutoCompleteAddress": [
- 264,
+ 265,
{
"address": [
1,
@@ -6460,7 +6471,7 @@ export default {
}
],
"getAddressDetails": [
- 266,
+ 267,
{
"placeId": [
1,
@@ -6473,24 +6484,21 @@ export default {
}
],
"getUsageAnalytics": [
- 253,
+ 258,
{
"input": [
- 333
+ 334
]
}
],
"findManyPublicDomains": [
- 259
- ],
- "getEmailingDomains": [
- 261
+ 264
],
"findManyMarketplaceApps": [
- 257
+ 262
],
"findMarketplaceAppDetail": [
- 258,
+ 263,
{
"universalIdentifier": [
1,
@@ -6512,7 +6520,7 @@ export default {
},
"LogicFunctionIdInput": {
"id": [
- 293
+ 294
],
"__typename": [
1
@@ -6528,10 +6536,10 @@ export default {
},
"EventLogQueryInput": {
"table": [
- 327
+ 328
],
"filters": [
- 328
+ 329
],
"first": [
21
@@ -6552,7 +6560,7 @@ export default {
1
],
"dateRange": [
- 329
+ 330
],
"recordId": [
1
@@ -6619,7 +6627,7 @@ export default {
1
],
"operationTypes": [
- 334
+ 335
],
"__typename": [
1
@@ -6631,7 +6639,7 @@ export default {
6,
{
"input": [
- 336,
+ 337,
"AddQuerySubscriptionInput!"
]
}
@@ -6640,49 +6648,49 @@ export default {
6,
{
"input": [
- 337,
+ 338,
"RemoveQueryFromEventStreamInput!"
]
}
],
"createManyNavigationMenuItems": [
- 157,
+ 162,
{
"inputs": [
- 338,
+ 339,
"[CreateNavigationMenuItemInput!]!"
]
}
],
"createNavigationMenuItem": [
- 157,
+ 162,
{
"input": [
- 338,
+ 339,
"CreateNavigationMenuItemInput!"
]
}
],
"updateManyNavigationMenuItems": [
- 157,
+ 162,
{
"inputs": [
- 339,
+ 340,
"[UpdateOneNavigationMenuItemInput!]!"
]
}
],
"updateNavigationMenuItem": [
- 157,
+ 162,
{
"input": [
- 339,
+ 340,
"UpdateOneNavigationMenuItemInput!"
]
}
],
"deleteManyNavigationMenuItems": [
- 157,
+ 162,
{
"ids": [
3,
@@ -6691,7 +6699,7 @@ export default {
}
],
"deleteNavigationMenuItem": [
- 157,
+ 162,
{
"id": [
3,
@@ -6700,10 +6708,10 @@ export default {
}
],
"uploadEmailAttachmentFile": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
]
}
@@ -6721,46 +6729,46 @@ export default {
}
],
"uploadAiChatFile": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
]
}
],
"uploadWorkflowFile": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
]
}
],
"uploadWorkspaceLogo": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
]
}
],
"uploadWorkspaceMemberProfilePicture": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
]
}
],
"uploadFilesFieldFile": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
],
"fieldMetadataId": [
@@ -6770,10 +6778,10 @@ export default {
}
],
"uploadFilesFieldFileByUniversalIdentifier": [
- 131,
+ 136,
{
"file": [
- 341,
+ 342,
"Upload!"
],
"fieldMetadataUniversalIdentifier": [
@@ -6786,7 +6794,7 @@ export default {
61,
{
"input": [
- 342,
+ 343,
"CreateViewFilterGroupInput!"
]
}
@@ -6799,7 +6807,7 @@ export default {
"String!"
],
"input": [
- 343,
+ 344,
"UpdateViewFilterGroupInput!"
]
}
@@ -6826,7 +6834,7 @@ export default {
63,
{
"input": [
- 344,
+ 345,
"CreateViewFilterInput!"
]
}
@@ -6835,7 +6843,7 @@ export default {
63,
{
"input": [
- 345,
+ 346,
"UpdateViewFilterInput!"
]
}
@@ -6844,7 +6852,7 @@ export default {
63,
{
"input": [
- 347,
+ 348,
"DeleteViewFilterInput!"
]
}
@@ -6853,7 +6861,7 @@ export default {
63,
{
"input": [
- 348,
+ 349,
"DestroyViewFilterInput!"
]
}
@@ -6862,7 +6870,7 @@ export default {
69,
{
"input": [
- 349,
+ 350,
"CreateViewInput!"
]
}
@@ -6875,7 +6883,7 @@ export default {
"String!"
],
"input": [
- 350,
+ 351,
"UpdateViewInput!"
]
}
@@ -6902,7 +6910,7 @@ export default {
69,
{
"input": [
- 351,
+ 352,
"UpsertViewWidgetInput!"
]
}
@@ -6911,7 +6919,7 @@ export default {
66,
{
"input": [
- 356,
+ 357,
"CreateViewSortInput!"
]
}
@@ -6920,7 +6928,7 @@ export default {
66,
{
"input": [
- 357,
+ 358,
"UpdateViewSortInput!"
]
}
@@ -6929,7 +6937,7 @@ export default {
6,
{
"input": [
- 359,
+ 360,
"DeleteViewSortInput!"
]
}
@@ -6938,7 +6946,7 @@ export default {
6,
{
"input": [
- 360,
+ 361,
"DestroyViewSortInput!"
]
}
@@ -6947,7 +6955,7 @@ export default {
59,
{
"input": [
- 361,
+ 362,
"UpdateViewFieldInput!"
]
}
@@ -6956,7 +6964,7 @@ export default {
59,
{
"input": [
- 363,
+ 364,
"CreateViewFieldInput!"
]
}
@@ -6965,7 +6973,7 @@ export default {
59,
{
"inputs": [
- 363,
+ 364,
"[CreateViewFieldInput!]!"
]
}
@@ -6974,7 +6982,7 @@ export default {
59,
{
"input": [
- 364,
+ 365,
"DeleteViewFieldInput!"
]
}
@@ -6983,7 +6991,7 @@ export default {
59,
{
"input": [
- 365,
+ 366,
"DestroyViewFieldInput!"
]
}
@@ -6992,7 +7000,7 @@ export default {
68,
{
"input": [
- 366,
+ 367,
"UpdateViewFieldGroupInput!"
]
}
@@ -7001,7 +7009,7 @@ export default {
68,
{
"input": [
- 368,
+ 369,
"CreateViewFieldGroupInput!"
]
}
@@ -7010,7 +7018,7 @@ export default {
68,
{
"inputs": [
- 368,
+ 369,
"[CreateViewFieldGroupInput!]!"
]
}
@@ -7019,7 +7027,7 @@ export default {
68,
{
"input": [
- 369,
+ 370,
"DeleteViewFieldGroupInput!"
]
}
@@ -7028,7 +7036,7 @@ export default {
68,
{
"input": [
- 370,
+ 371,
"DestroyViewFieldGroupInput!"
]
}
@@ -7037,7 +7045,7 @@ export default {
69,
{
"input": [
- 371,
+ 372,
"UpsertFieldsWidgetInput!"
]
}
@@ -7046,7 +7054,7 @@ export default {
2,
{
"input": [
- 374,
+ 375,
"CreateApiKeyInput!"
]
}
@@ -7055,7 +7063,7 @@ export default {
2,
{
"input": [
- 375,
+ 376,
"UpdateApiKeyInput!"
]
}
@@ -7064,7 +7072,7 @@ export default {
2,
{
"input": [
- 376,
+ 377,
"RevokeApiKeyInput!"
]
}
@@ -7106,7 +7114,7 @@ export default {
129,
{
"type": [
- 377,
+ 378,
"AnalyticsType!"
],
"name": [
@@ -7121,20 +7129,20 @@ export default {
}
],
"skipSyncEmailOnboardingStep": [
- 153
+ 158
],
"skipBookOnboardingStep": [
- 153
+ 158
],
"checkoutSession": [
- 151,
+ 156,
{
"recurringInterval": [
- 139,
+ 144,
"SubscriptionInterval!"
],
"plan": [
- 135,
+ 140,
"BillingPlanKey!"
],
"requirePaymentMethod": [
@@ -7147,19 +7155,19 @@ export default {
}
],
"switchSubscriptionInterval": [
- 152
+ 157
],
"switchBillingPlan": [
- 152
+ 157
],
"cancelSwitchBillingPlan": [
- 152
+ 157
],
"cancelSwitchBillingInterval": [
- 152
+ 157
],
"setResourceCreditSubscriptionPrice": [
- 152,
+ 157,
{
"priceId": [
1,
@@ -7168,10 +7176,10 @@ export default {
}
],
"endSubscriptionTrialPeriod": [
- 148
+ 153
],
"cancelSwitchResourceCreditPrice": [
- 152
+ 157
],
"deleteWorkspaceInvitation": [
1,
@@ -7183,7 +7191,7 @@ export default {
}
],
"resendWorkspaceInvitation": [
- 155,
+ 160,
{
"appTokenId": [
1,
@@ -7192,7 +7200,7 @@ export default {
}
],
"sendInvitations": [
- 155,
+ 160,
{
"emails": [
1,
@@ -7204,10 +7212,10 @@ export default {
}
],
"createApprovedAccessDomain": [
- 130,
+ 135,
{
"input": [
- 378,
+ 379,
"CreateApprovedAccessDomainInput!"
]
}
@@ -7216,16 +7224,16 @@ export default {
6,
{
"input": [
- 379,
+ 380,
"DeleteApprovedAccessDomainInput!"
]
}
],
"validateApprovedAccessDomain": [
- 130,
+ 135,
{
"input": [
- 380,
+ 381,
"ValidateApprovedAccessDomainInput!"
]
}
@@ -7234,7 +7242,7 @@ export default {
122,
{
"input": [
- 381,
+ 382,
"CreatePageLayoutTabInput!"
]
}
@@ -7247,7 +7255,7 @@ export default {
"String!"
],
"input": [
- 382,
+ 383,
"UpdatePageLayoutTabInput!"
]
}
@@ -7265,7 +7273,7 @@ export default {
123,
{
"input": [
- 383,
+ 384,
"CreatePageLayoutInput!"
]
}
@@ -7278,7 +7286,7 @@ export default {
"String!"
],
"input": [
- 384,
+ 385,
"UpdatePageLayoutInput!"
]
}
@@ -7300,7 +7308,7 @@ export default {
"String!"
],
"input": [
- 385,
+ 386,
"UpdatePageLayoutWithTabsInput!"
]
}
@@ -7332,6 +7340,46 @@ export default {
]
}
],
+ "createEmailingDomain": [
+ 131,
+ {
+ "domain": [
+ 1,
+ "String!"
+ ],
+ "driver": [
+ 132,
+ "EmailingDomainDriver!"
+ ]
+ }
+ ],
+ "deleteEmailingDomain": [
+ 6,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "verifyEmailingDomain": [
+ 131,
+ {
+ "id": [
+ 1,
+ "String!"
+ ]
+ }
+ ],
+ "sendEmailViaEmailingDomain": [
+ 134,
+ {
+ "input": [
+ 390,
+ "SendEmailViaDomainInput!"
+ ]
+ }
+ ],
"updateOneApplicationVariable": [
6,
{
@@ -7353,7 +7401,7 @@ export default {
84,
{
"input": [
- 389,
+ 391,
"CreatePageLayoutWidgetInput!"
]
}
@@ -7366,7 +7414,7 @@ export default {
"String!"
],
"input": [
- 390,
+ 392,
"UpdatePageLayoutWidgetInput!"
]
}
@@ -7384,7 +7432,7 @@ export default {
41,
{
"input": [
- 324,
+ 325,
"LogicFunctionIdInput!"
]
}
@@ -7393,16 +7441,16 @@ export default {
41,
{
"input": [
- 391,
+ 393,
"CreateLogicFunctionFromSourceInput!"
]
}
],
"executeOneLogicFunction": [
- 166,
+ 171,
{
"input": [
- 392,
+ 394,
"ExecuteOneLogicFunctionInput!"
]
}
@@ -7411,7 +7459,7 @@ export default {
6,
{
"input": [
- 393,
+ 395,
"UpdateLogicFunctionFromSourceInput!"
]
}
@@ -7420,7 +7468,7 @@ export default {
35,
{
"input": [
- 395,
+ 397,
"CreateCommandMenuItemInput!"
]
}
@@ -7429,7 +7477,7 @@ export default {
35,
{
"input": [
- 396,
+ 398,
"UpdateCommandMenuItemInput!"
]
}
@@ -7447,7 +7495,7 @@ export default {
34,
{
"input": [
- 397,
+ 399,
"CreateFrontComponentInput!"
]
}
@@ -7456,7 +7504,7 @@ export default {
34,
{
"input": [
- 398,
+ 400,
"UpdateFrontComponentInput!"
]
}
@@ -7474,7 +7522,7 @@ export default {
55,
{
"input": [
- 400,
+ 402,
"CreateOneObjectInput!"
]
}
@@ -7483,7 +7531,7 @@ export default {
55,
{
"input": [
- 402,
+ 404,
"DeleteOneObjectInput!"
]
}
@@ -7492,7 +7540,7 @@ export default {
55,
{
"input": [
- 403,
+ 405,
"UpdateOneObjectInput!"
]
}
@@ -7501,7 +7549,7 @@ export default {
46,
{
"input": [
- 405,
+ 407,
"CreateOneIndexInput!"
]
}
@@ -7510,7 +7558,7 @@ export default {
46,
{
"input": [
- 408,
+ 410,
"DeleteOneIndexInput!"
]
}
@@ -7519,7 +7567,7 @@ export default {
25,
{
"input": [
- 409,
+ 411,
"CreateAgentInput!"
]
}
@@ -7528,7 +7576,7 @@ export default {
25,
{
"input": [
- 410,
+ 412,
"UpdateAgentInput!"
]
}
@@ -7537,7 +7585,7 @@ export default {
25,
{
"input": [
- 325,
+ 326,
"AgentIdInput!"
]
}
@@ -7559,7 +7607,7 @@ export default {
29,
{
"createRoleInput": [
- 411,
+ 413,
"CreateRoleInput!"
]
}
@@ -7568,7 +7616,7 @@ export default {
29,
{
"updateRoleInput": [
- 412,
+ 414,
"UpdateRoleInput!"
]
}
@@ -7586,7 +7634,7 @@ export default {
16,
{
"upsertObjectPermissionsInput": [
- 414,
+ 416,
"UpsertObjectPermissionsInput!"
]
}
@@ -7595,7 +7643,7 @@ export default {
27,
{
"upsertPermissionFlagsInput": [
- 416,
+ 418,
"UpsertPermissionFlagsInput!"
]
}
@@ -7604,16 +7652,16 @@ export default {
26,
{
"upsertFieldPermissionsInput": [
- 417,
+ 419,
"UpsertFieldPermissionsInput!"
]
}
],
"upsertRowLevelPermissionPredicates": [
- 229,
+ 234,
{
"input": [
- 419,
+ 421,
"UpsertRowLevelPermissionPredicatesInput!"
]
}
@@ -7641,25 +7689,25 @@ export default {
}
],
"createWebhook": [
- 274,
+ 275,
{
"input": [
- 422,
+ 424,
"CreateWebhookInput!"
]
}
],
"updateWebhook": [
- 274,
+ 275,
{
"input": [
- 423,
+ 425,
"UpdateWebhookInput!"
]
}
],
"deleteWebhook": [
- 274,
+ 275,
{
"id": [
3,
@@ -7671,7 +7719,7 @@ export default {
43,
{
"input": [
- 425,
+ 427,
"CreateOneFieldMetadataInput!"
]
}
@@ -7680,7 +7728,7 @@ export default {
43,
{
"input": [
- 427,
+ 429,
"UpdateOneFieldMetadataInput!"
]
}
@@ -7689,7 +7737,7 @@ export default {
43,
{
"input": [
- 429,
+ 431,
"DeleteOneFieldInput!"
]
}
@@ -7698,7 +7746,7 @@ export default {
65,
{
"input": [
- 430,
+ 432,
"CreateViewGroupInput!"
]
}
@@ -7707,7 +7755,7 @@ export default {
65,
{
"inputs": [
- 430,
+ 432,
"[CreateViewGroupInput!]!"
]
}
@@ -7716,7 +7764,7 @@ export default {
65,
{
"input": [
- 431,
+ 433,
"UpdateViewGroupInput!"
]
}
@@ -7725,7 +7773,7 @@ export default {
65,
{
"inputs": [
- 431,
+ 433,
"[UpdateViewGroupInput!]!"
]
}
@@ -7734,7 +7782,7 @@ export default {
65,
{
"input": [
- 433,
+ 435,
"DeleteViewGroupInput!"
]
}
@@ -7743,49 +7791,49 @@ export default {
65,
{
"input": [
- 434,
+ 436,
"DestroyViewGroupInput!"
]
}
],
"updateMessageFolder": [
- 315,
+ 316,
{
"input": [
- 435,
+ 437,
"UpdateMessageFolderInput!"
]
}
],
"updateMessageFolders": [
- 315,
+ 316,
{
"input": [
- 437,
+ 439,
"UpdateMessageFoldersInput!"
]
}
],
"updateMessageChannel": [
- 306,
+ 307,
{
"input": [
- 438,
+ 440,
"UpdateMessageChannelInput!"
]
}
],
"createEmailGroupChannel": [
- 314,
+ 315,
{
"input": [
- 440,
+ 442,
"CreateEmailGroupChannelInput!"
]
}
],
"deleteEmailGroupChannel": [
- 306,
+ 307,
{
"id": [
3,
@@ -7794,7 +7842,7 @@ export default {
}
],
"deleteConnectedAccount": [
- 269,
+ 270,
{
"id": [
3,
@@ -7803,19 +7851,19 @@ export default {
}
],
"updateCalendarChannel": [
- 301,
+ 302,
{
"input": [
- 441,
+ 443,
"UpdateCalendarChannelInput!"
]
}
],
"createChatThread": [
- 292
+ 293
],
"sendChatMessage": [
- 297,
+ 298,
{
"threadId": [
3,
@@ -7836,7 +7884,7 @@ export default {
1
],
"fileAttachments": [
- 443,
+ 445,
"[FileAttachmentInput!]"
]
}
@@ -7851,7 +7899,7 @@ export default {
}
],
"renameChatThread": [
- 292,
+ 293,
{
"id": [
3,
@@ -7864,7 +7912,7 @@ export default {
}
],
"archiveChatThread": [
- 292,
+ 293,
{
"id": [
3,
@@ -7873,7 +7921,7 @@ export default {
}
],
"unarchiveChatThread": [
- 292,
+ 293,
{
"id": [
3,
@@ -7900,25 +7948,25 @@ export default {
}
],
"createSkill": [
- 290,
+ 291,
{
"input": [
- 444,
+ 446,
"CreateSkillInput!"
]
}
],
"updateSkill": [
- 290,
+ 291,
{
"input": [
- 445,
+ 447,
"UpdateSkillInput!"
]
}
],
"deleteSkill": [
- 290,
+ 291,
{
"id": [
3,
@@ -7927,7 +7975,7 @@ export default {
}
],
"activateSkill": [
- 290,
+ 291,
{
"id": [
3,
@@ -7936,7 +7984,7 @@ export default {
}
],
"deactivateSkill": [
- 290,
+ 291,
{
"id": [
3,
@@ -7945,7 +7993,7 @@ export default {
}
],
"evaluateAgentTurn": [
- 299,
+ 300,
{
"turnId": [
3,
@@ -7954,7 +8002,7 @@ export default {
}
],
"runEvaluationInput": [
- 300,
+ 301,
{
"agentId": [
3,
@@ -7967,16 +8015,16 @@ export default {
}
],
"getAuthorizationUrlForSSO": [
- 238,
+ 243,
{
"input": [
- 446,
+ 448,
"GetAuthorizationUrlForSSOInput!"
]
}
],
"getLoginTokenFromCredentials": [
- 247,
+ 252,
{
"email": [
1,
@@ -8002,7 +8050,7 @@ export default {
}
],
"signIn": [
- 236,
+ 241,
{
"email": [
1,
@@ -8024,7 +8072,7 @@ export default {
}
],
"verifyEmailAndGetLoginToken": [
- 244,
+ 249,
{
"emailVerificationToken": [
1,
@@ -8044,7 +8092,7 @@ export default {
}
],
"verifyEmailAndGetWorkspaceAgnosticToken": [
- 236,
+ 241,
{
"emailVerificationToken": [
1,
@@ -8060,7 +8108,7 @@ export default {
}
],
"getAuthTokensFromOTP": [
- 246,
+ 251,
{
"otp": [
1,
@@ -8080,7 +8128,7 @@ export default {
}
],
"signUp": [
- 236,
+ 241,
{
"email": [
1,
@@ -8102,7 +8150,7 @@ export default {
}
],
"signUpInWorkspace": [
- 241,
+ 246,
{
"email": [
1,
@@ -8133,13 +8181,13 @@ export default {
}
],
"signUpInNewWorkspace": [
- 241
+ 246
],
"generateTransientToken": [
- 242
+ 247
],
"getAuthTokensFromLoginToken": [
- 246,
+ 251,
{
"loginToken": [
1,
@@ -8152,7 +8200,7 @@ export default {
}
],
"authorizeApp": [
- 234,
+ 239,
{
"clientId": [
1,
@@ -8174,7 +8222,7 @@ export default {
}
],
"renewToken": [
- 246,
+ 251,
{
"appToken": [
1,
@@ -8183,7 +8231,7 @@ export default {
}
],
"generateApiKeyToken": [
- 245,
+ 250,
{
"apiKeyId": [
3,
@@ -8196,7 +8244,7 @@ export default {
}
],
"emailPasswordResetLink": [
- 237,
+ 242,
{
"email": [
1,
@@ -8208,7 +8256,7 @@ export default {
}
],
"updatePasswordViaResetToken": [
- 239,
+ 244,
{
"passwordResetToken": [
1,
@@ -8221,10 +8269,10 @@ export default {
}
],
"createApplicationRegistration": [
- 197,
+ 202,
{
"input": [
- 447,
+ 449,
"CreateApplicationRegistrationInput!"
]
}
@@ -8233,7 +8281,7 @@ export default {
7,
{
"input": [
- 448,
+ 450,
"UpdateApplicationRegistrationInput!"
]
}
@@ -8248,7 +8296,7 @@ export default {
}
],
"rotateApplicationRegistrationClientSecret": [
- 199,
+ 204,
{
"id": [
1,
@@ -8260,7 +8308,7 @@ export default {
5,
{
"input": [
- 450,
+ 452,
"CreateApplicationRegistrationVariableInput!"
]
}
@@ -8269,7 +8317,7 @@ export default {
5,
{
"input": [
- 451,
+ 453,
"UpdateApplicationRegistrationVariableInput!"
]
}
@@ -8287,7 +8335,7 @@ export default {
7,
{
"file": [
- 341,
+ 342,
"Upload!"
],
"universalIdentifier": [
@@ -8309,7 +8357,7 @@ export default {
}
],
"initiateOTPProvisioning": [
- 232,
+ 237,
{
"loginToken": [
1,
@@ -8322,10 +8370,10 @@ export default {
}
],
"initiateOTPProvisioningForAuthenticatedUser": [
- 232
+ 237
],
"deleteTwoFactorAuthenticationMethod": [
- 231,
+ 236,
{
"twoFactorAuthenticationMethodId": [
3,
@@ -8334,7 +8382,7 @@ export default {
}
],
"verifyTwoFactorAuthenticationMethodForAuthenticatedUser": [
- 233,
+ 238,
{
"otp": [
1,
@@ -8358,7 +8406,7 @@ export default {
6,
{
"input": [
- 453,
+ 455,
"UpdateWorkspaceMemberSettingsInput!"
]
}
@@ -8376,7 +8424,7 @@ export default {
}
],
"resendEmailVerificationToken": [
- 215,
+ 220,
{
"email": [
1,
@@ -8392,7 +8440,7 @@ export default {
75,
{
"data": [
- 454,
+ 456,
"ActivateWorkspaceInput!"
]
}
@@ -8401,7 +8449,7 @@ export default {
75,
{
"data": [
- 455,
+ 457,
"UpdateWorkspaceInput!"
]
}
@@ -8410,13 +8458,13 @@ export default {
75
],
"checkCustomDomainValidRecords": [
- 228
+ 233
],
"runWorkspaceMigration": [
6,
{
"workspaceMigration": [
- 456,
+ 458,
"WorkspaceMigrationInput!"
]
}
@@ -8431,43 +8479,43 @@ export default {
}
],
"createOIDCIdentityProvider": [
- 220,
+ 225,
{
"input": [
- 459,
+ 461,
"SetupOIDCSsoInput!"
]
}
],
"createSAMLIdentityProvider": [
- 220,
+ 225,
{
"input": [
- 460,
+ 462,
"SetupSAMLSsoInput!"
]
}
],
"deleteSSOIdentityProvider": [
- 216,
+ 221,
{
"input": [
- 461,
+ 463,
"DeleteSsoInput!"
]
}
],
"editSSOIdentityProvider": [
- 217,
+ 222,
{
"input": [
- 462,
+ 464,
"EditSsoInput!"
]
}
],
"duplicateDashboard": [
- 285,
+ 286,
{
"id": [
3,
@@ -8476,7 +8524,7 @@ export default {
}
],
"impersonate": [
- 250,
+ 255,
{
"userId": [
3,
@@ -8489,16 +8537,16 @@ export default {
}
],
"sendEmail": [
- 286,
+ 287,
{
"input": [
- 463,
+ 465,
"SendEmailInput!"
]
}
],
"startChannelSync": [
- 277,
+ 278,
{
"connectedAccountId": [
3,
@@ -8507,14 +8555,14 @@ export default {
}
],
"saveImapSmtpCaldavAccount": [
- 273,
+ 274,
{
"handle": [
1,
"String!"
],
"connectionParameters": [
- 465,
+ 467,
"EmailAccountConnectionParameters!"
],
"id": [
@@ -8523,16 +8571,16 @@ export default {
}
],
"updateLabPublicFeatureFlag": [
- 168,
+ 173,
{
"input": [
- 467,
+ 469,
"UpdateLabPublicFeatureFlagInput!"
]
}
],
"createPublicDomain": [
- 259,
+ 264,
{
"domain": [
1,
@@ -8544,7 +8592,7 @@ export default {
}
],
"updatePublicDomain": [
- 259,
+ 264,
{
"domain": [
1,
@@ -8565,7 +8613,7 @@ export default {
}
],
"checkPublicDomainValidRecords": [
- 228,
+ 233,
{
"domain": [
1,
@@ -8573,42 +8621,11 @@ export default {
]
}
],
- "createEmailingDomain": [
- 261,
- {
- "domain": [
- 1,
- "String!"
- ],
- "driver": [
- 262,
- "EmailingDomainDriver!"
- ]
- }
- ],
- "deleteEmailingDomain": [
- 6,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
- "verifyEmailingDomain": [
- 261,
- {
- "id": [
- 1,
- "String!"
- ]
- }
- ],
"createOneAppToken": [
77,
{
"input": [
- 468,
+ 470,
"CreateOneAppTokenInput!"
]
}
@@ -8641,7 +8658,7 @@ export default {
6
],
"createDevelopmentApplication": [
- 254,
+ 259,
{
"universalIdentifier": [
1,
@@ -8663,7 +8680,7 @@ export default {
}
],
"syncApplication": [
- 255,
+ 260,
{
"manifest": [
15,
@@ -8672,10 +8689,10 @@ export default {
}
],
"uploadApplicationFile": [
- 256,
+ 261,
{
"file": [
- 341,
+ 342,
"Upload!"
],
"applicationUniversalIdentifier": [
@@ -8683,7 +8700,7 @@ export default {
"String!"
],
"fileFolder": [
- 470,
+ 472,
"FileFolder!"
],
"filePath": [
@@ -8760,7 +8777,7 @@ export default {
3
],
"type": [
- 158
+ 163
],
"name": [
1
@@ -8792,7 +8809,7 @@ export default {
3
],
"update": [
- 340
+ 341
],
"__typename": [
1
@@ -8902,7 +8919,7 @@ export default {
3
],
"update": [
- 346
+ 347
],
"__typename": [
1
@@ -9061,17 +9078,17 @@ export default {
3
],
"viewFields": [
- 352
- ],
- "viewFilters": [
353
],
- "viewFilterGroups": [
+ "viewFilters": [
354
],
- "viewSorts": [
+ "viewFilterGroups": [
355
],
+ "viewSorts": [
+ 356
+ ],
"__typename": [
1
]
@@ -9181,7 +9198,7 @@ export default {
3
],
"update": [
- 358
+ 359
],
"__typename": [
1
@@ -9219,7 +9236,7 @@ export default {
3
],
"update": [
- 362
+ 363
],
"__typename": [
1
@@ -9295,7 +9312,7 @@ export default {
3
],
"update": [
- 367
+ 368
],
"__typename": [
1
@@ -9359,10 +9376,10 @@ export default {
3
],
"groups": [
- 372
+ 373
],
"fields": [
- 373
+ 374
],
"__typename": [
1
@@ -9382,7 +9399,7 @@ export default {
6
],
"fields": [
- 373
+ 374
],
"__typename": [
1
@@ -9551,7 +9568,7 @@ export default {
3
],
"tabs": [
- 386
+ 387
],
"__typename": [
1
@@ -9574,7 +9591,7 @@ export default {
88
],
"widgets": [
- 387
+ 388
],
"__typename": [
1
@@ -9597,7 +9614,7 @@ export default {
3
],
"gridPosition": [
- 388
+ 389
],
"position": [
15
@@ -9632,6 +9649,38 @@ export default {
1
]
},
+ "SendEmailViaDomainInput": {
+ "emailingDomainId": [
+ 1
+ ],
+ "to": [
+ 1
+ ],
+ "cc": [
+ 1
+ ],
+ "bcc": [
+ 1
+ ],
+ "subject": [
+ 1
+ ],
+ "text": [
+ 1
+ ],
+ "html": [
+ 1
+ ],
+ "from": [
+ 1
+ ],
+ "replyTo": [
+ 1
+ ],
+ "__typename": [
+ 1
+ ]
+ },
"CreatePageLayoutWidgetInput": {
"pageLayoutTabId": [
3
@@ -9646,7 +9695,7 @@ export default {
3
],
"gridPosition": [
- 388
+ 389
],
"position": [
15
@@ -9672,7 +9721,7 @@ export default {
3
],
"gridPosition": [
- 388
+ 389
],
"position": [
15
@@ -9744,7 +9793,7 @@ export default {
3
],
"update": [
- 394
+ 396
],
"__typename": [
1
@@ -9904,7 +9953,7 @@ export default {
3
],
"update": [
- 399
+ 401
],
"__typename": [
1
@@ -9923,7 +9972,7 @@ export default {
},
"CreateOneObjectInput": {
"object": [
- 401
+ 403
],
"__typename": [
1
@@ -9983,7 +10032,7 @@ export default {
},
"UpdateOneObjectInput": {
"update": [
- 404
+ 406
],
"id": [
3
@@ -10038,7 +10087,7 @@ export default {
},
"CreateOneIndexInput": {
"index": [
- 406
+ 408
],
"__typename": [
1
@@ -10049,7 +10098,7 @@ export default {
3
],
"fields": [
- 407
+ 409
],
"indexType": [
47
@@ -10196,7 +10245,7 @@ export default {
},
"UpdateRoleInput": {
"update": [
- 413
+ 415
],
"id": [
3
@@ -10251,7 +10300,7 @@ export default {
3
],
"objectPermissions": [
- 415
+ 417
],
"__typename": [
1
@@ -10293,7 +10342,7 @@ export default {
3
],
"fieldPermissions": [
- 418
+ 420
],
"__typename": [
1
@@ -10324,10 +10373,10 @@ export default {
3
],
"predicates": [
- 420
+ 422
],
"predicateGroups": [
- 421
+ 423
],
"__typename": [
1
@@ -10410,7 +10459,7 @@ export default {
3
],
"update": [
- 424
+ 426
],
"__typename": [
1
@@ -10435,7 +10484,7 @@ export default {
},
"CreateOneFieldMetadataInput": {
"field": [
- 426
+ 428
],
"__typename": [
1
@@ -10508,7 +10557,7 @@ export default {
3
],
"update": [
- 428
+ 430
],
"__typename": [
1
@@ -10600,7 +10649,7 @@ export default {
3
],
"update": [
- 432
+ 434
],
"__typename": [
1
@@ -10644,7 +10693,7 @@ export default {
3
],
"update": [
- 436
+ 438
],
"__typename": [
1
@@ -10663,7 +10712,7 @@ export default {
3
],
"update": [
- 436
+ 438
],
"__typename": [
1
@@ -10674,7 +10723,7 @@ export default {
3
],
"update": [
- 439
+ 441
],
"__typename": [
1
@@ -10682,16 +10731,16 @@ export default {
},
"UpdateMessageChannelInputUpdates": {
"visibility": [
- 307
+ 308
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
- 309
+ 310
],
"messageFolderImportPolicy": [
- 310
+ 311
],
"isSyncEnabled": [
6
@@ -10719,7 +10768,7 @@ export default {
3
],
"update": [
- 442
+ 444
],
"__typename": [
1
@@ -10727,13 +10776,13 @@ export default {
},
"UpdateCalendarChannelInputUpdates": {
"visibility": [
- 304
+ 305
],
"isContactAutoCreationEnabled": [
6
],
"contactAutoCreationPolicy": [
- 305
+ 306
],
"isSyncEnabled": [
6
@@ -10835,7 +10884,7 @@ export default {
1
],
"update": [
- 449
+ 451
],
"__typename": [
1
@@ -10883,7 +10932,7 @@ export default {
1
],
"update": [
- 452
+ 454
],
"__typename": [
1
@@ -11001,7 +11050,7 @@ export default {
},
"WorkspaceMigrationInput": {
"actions": [
- 457
+ 459
],
"__typename": [
1
@@ -11009,10 +11058,10 @@ export default {
},
"WorkspaceMigrationDeleteActionInput": {
"type": [
- 458
+ 460
],
"metadataName": [
- 318
+ 319
],
"universalIdentifier": [
1
@@ -11075,7 +11124,7 @@ export default {
3
],
"status": [
- 175
+ 180
],
"__typename": [
1
@@ -11104,7 +11153,7 @@ export default {
1
],
"files": [
- 464
+ 466
],
"__typename": [
1
@@ -11123,13 +11172,13 @@ export default {
},
"EmailAccountConnectionParameters": {
"IMAP": [
- 466
+ 468
],
"SMTP": [
- 466
+ 468
],
"CALDAV": [
- 466
+ 468
],
"__typename": [
1
@@ -11168,7 +11217,7 @@ export default {
},
"CreateOneAppTokenInput": {
"appToken": [
- 469
+ 471
],
"__typename": [
1
@@ -11185,7 +11234,7 @@ export default {
"FileFolder": {},
"Subscription": {
"onEventSubscription": [
- 165,
+ 170,
{
"eventStreamId": [
1,
@@ -11194,16 +11243,16 @@ export default {
}
],
"logicFunctionLogs": [
- 230,
+ 235,
{
"input": [
- 472,
+ 474,
"LogicFunctionLogsInput!"
]
}
],
"onAgentChatEvent": [
- 298,
+ 299,
{
"threadId": [
3,
diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts
index 3d92c3ba24..0973f9231a 100644
--- a/packages/twenty-front/src/generated-admin/graphql.ts
+++ b/packages/twenty-front/src/generated-admin/graphql.ts
@@ -283,7 +283,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
- IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 87e6bf3d6a..23ec0cb7d8 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1635,7 +1635,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
- IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
@@ -2504,6 +2503,7 @@ export type Mutation = {
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess;
sendChatMessage: SendChatMessageResult;
sendEmail: SendEmailOutput;
+ sendEmailViaEmailingDomain: SendEmailViaDomainOutput;
sendInvitations: SendInvitations;
setEnterpriseKey: EnterpriseLicenseInfoDto;
setResourceCreditSubscriptionPrice: BillingUpdate;
@@ -3230,6 +3230,11 @@ export type MutationSendEmailArgs = {
};
+export type MutationSendEmailViaEmailingDomainArgs = {
+ input: SendEmailViaDomainInput;
+};
+
+
export type MutationSendInvitationsArgs = {
emails: Array;
roleId?: InputMaybe;
@@ -4785,6 +4790,23 @@ export type SendEmailOutput = {
success: Scalars['Boolean'];
};
+export type SendEmailViaDomainInput = {
+ bcc?: InputMaybe>;
+ cc?: InputMaybe>;
+ emailingDomainId: Scalars['String'];
+ from: Scalars['String'];
+ html?: InputMaybe;
+ replyTo?: InputMaybe>;
+ subject: Scalars['String'];
+ text: Scalars['String'];
+ to: Array;
+};
+
+export type SendEmailViaDomainOutput = {
+ __typename?: 'SendEmailViaDomainOutput';
+ messageId: Scalars['String'];
+};
+
export type SendInvitations = {
__typename?: 'SendInvitations';
errors: Array;
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx
index 26f98d453a..4159a61b30 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx
@@ -39,14 +39,14 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
}
} catch {
enqueueErrorSnackBar({
- message: t`Failed to create email group channel. Email group may not be configured on this server.`,
+ message: t`Failed to create email handle. Email handles may not be configured on this server.`,
});
}
}, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]);
return (
{
children: t`General`,
href: getSettingsPath(SettingsPath.Workspace),
},
- { children: t`New Email Group` },
+ { children: t`New Email Handle` },
]}
actionButton={
{
{record.type}
{
copyToClipboard(record.key || '');
}}
@@ -101,6 +102,7 @@ export const SettingsDnsRecordsTable = ({
{
copyToClipboard(record.value);
}}
@@ -111,17 +113,17 @@ export const SettingsDnsRecordsTable = ({
{hasPriorityRecords && (
- {record.priority}
+ {record.priority}
)}
{hasTtlRecords && (
- {record.ttl}
+ {record.ttl}
)}
{hasStatusRecords && (
-
+
{'status' in record ? (
`
+ ${({ clickable }) =>
+ clickable
+ ? `
+ > * {
+ &:hover {
+ background-color: ${themeCssVariables.background.transparent.light};
+ cursor: pointer;
+ }
+ }
+ `
+ : ''}
+`;
+
+const StyledFooter = styled.div`
+ border-top: 1px solid ${themeCssVariables.border.color.light};
+ display: flex;
+ justify-content: flex-end;
+ padding-top: ${themeCssVariables.spacing[2]};
+`;
+
+const HEADER_PADDING = `0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[2]}`;
+
+export type SettingsTableListSectionColumn- = {
+ label: string;
+ align?: 'left' | 'right';
+ Cell: ComponentType<{ item: Item }>;
+};
+
+type SettingsTableListSectionProps
- = {
+ title: string;
+ description: string;
+ items: Item[];
+ columns: SettingsTableListSectionColumn
- [];
+ gridAutoColumns: string;
+ onRowClick?: (item: Item) => void;
+ footerButtonLabel: string;
+ onFooterButtonClick: () => void;
+};
+
+export const SettingsTableListSection = <
+ Item extends { id: string } = { id: string },
+>({
+ title,
+ description,
+ items,
+ columns,
+ gridAutoColumns,
+ onRowClick,
+ footerButtonLabel,
+ onFooterButtonClick,
+}: SettingsTableListSectionProps
- ) => (
+
+
+ {items.length > 0 && (
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+ {items.map((item) => (
+
+ onRowClick(item) : undefined}
+ >
+ {columns.map((column) => (
+
+
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+);
diff --git a/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainNameCell.tsx b/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainNameCell.tsx
new file mode 100644
index 0000000000..fbbf2aa41e
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainNameCell.tsx
@@ -0,0 +1,27 @@
+import { styled } from '@linaria/react';
+
+import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
+import { IconMail, OverflowingTextWithTooltip } from 'twenty-ui/display';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+const StyledNameCell = styled.div`
+ align-items: center;
+ color: ${themeCssVariables.font.color.primary};
+ display: flex;
+ font-weight: ${themeCssVariables.font.weight.medium};
+ gap: ${themeCssVariables.spacing[2]};
+ min-width: 0;
+`;
+
+type SettingsEmailingDomainNameCellProps = {
+ item: GetEmailingDomainsQuery['getEmailingDomains'][0];
+};
+
+export const SettingsEmailingDomainNameCell = ({
+ item,
+}: SettingsEmailingDomainNameCellProps) => (
+
+
+
+
+);
diff --git a/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu.tsx
deleted file mode 100644
index 44f2c51774..0000000000
--- a/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
-import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
-import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
-import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
-import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
-import { CombinedGraphQLErrors } from '@apollo/client/errors';
-import { t } from '@lingui/core/macro';
-import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
-import { LightIconButton } from 'twenty-ui/input';
-import { MenuItem } from 'twenty-ui/navigation';
-import { useMutation, useQuery } from '@apollo/client/react';
-import {
- type GetEmailingDomainsQuery,
- DeleteEmailingDomainDocument,
- GetEmailingDomainsDocument,
-} from '~/generated-metadata/graphql';
-
-type SettingsEmailingDomainRowDropdownMenuProps = {
- emailingDomain: GetEmailingDomainsQuery['getEmailingDomains'][0];
-};
-
-export const SettingsEmailingDomainRowDropdownMenu = ({
- emailingDomain,
-}: SettingsEmailingDomainRowDropdownMenuProps) => {
- const dropdownId = `settings-emailing-domain-row-${emailingDomain.id}`;
-
- const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
-
- const { closeDropdown } = useCloseDropdown();
-
- const { refetch: refetchEmailingDomains } = useQuery(
- GetEmailingDomainsDocument,
- );
-
- const [deleteEmailingDomainMutation] = useMutation(
- DeleteEmailingDomainDocument,
- );
-
- const handleDeleteEmailingDomain = async () => {
- try {
- await deleteEmailingDomainMutation({
- variables: {
- id: emailingDomain.id,
- },
- });
-
- enqueueSuccessSnackBar({
- message: t`Emailing domain deleted successfully`,
- });
-
- await refetchEmailingDomains();
- } catch (error) {
- enqueueErrorSnackBar({
- ...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
- });
- }
- };
-
- return (
-
- }
- dropdownComponents={
-
-
-
-
- }
- />
- );
-};
diff --git a/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainStatusCell.tsx b/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainStatusCell.tsx
new file mode 100644
index 0000000000..a03031daab
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/emailing-domains/components/SettingsEmailingDomainStatusCell.tsx
@@ -0,0 +1,17 @@
+import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
+import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
+import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
+import { Status } from 'twenty-ui/display';
+
+type SettingsEmailingDomainStatusCellProps = {
+ item: GetEmailingDomainsQuery['getEmailingDomains'][0];
+};
+
+export const SettingsEmailingDomainStatusCell = ({
+ item,
+}: SettingsEmailingDomainStatusCellProps) => (
+
+);
diff --git a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell.tsx b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell.tsx
new file mode 100644
index 0000000000..7d080231f7
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell.tsx
@@ -0,0 +1,23 @@
+import { styled } from '@linaria/react';
+
+import { type MessageChannel } from '@/accounts/types/MessageChannel';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+const StyledForwardingCell = styled.div`
+ color: ${themeCssVariables.font.color.tertiary};
+ font-family: monospace;
+ font-size: ${themeCssVariables.font.size.sm};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+type SettingsWorkspaceEmailGroupForwardingCellProps = {
+ item: MessageChannel;
+};
+
+export const SettingsWorkspaceEmailGroupForwardingCell = ({
+ item,
+}: SettingsWorkspaceEmailGroupForwardingCellProps) => (
+ {item.handle}
+);
diff --git a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx
index baed606b5b..afb2d992c6 100644
--- a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx
+++ b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx
@@ -1,65 +1,13 @@
-import { styled } from '@linaria/react';
-import { Trans, useLingui } from '@lingui/react/macro';
+import { useLingui } from '@lingui/react/macro';
+import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
-import { Table } from '@/ui/layout/table/components/Table';
-import { TableCell } from '@/ui/layout/table/components/TableCell';
-import { TableHeader } from '@/ui/layout/table/components/TableHeader';
-import { TableRow } from '@/ui/layout/table/components/TableRow';
+import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
+import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
+import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
-import { H2Title, IconMail, IconPlus } from 'twenty-ui/display';
-import { Button } from 'twenty-ui/input';
-import { Section } from 'twenty-ui/layout';
-import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
-const GRID_AUTO_COLUMNS = '1fr 1fr';
-
-const StyledTableRows = styled.div`
- padding-bottom: ${themeCssVariables.spacing[2]};
- padding-top: ${themeCssVariables.spacing[2]};
-`;
-
-const StyledClickableRow = styled.div`
- > * {
- &:hover {
- background-color: ${themeCssVariables.background.transparent.light};
- cursor: pointer;
- }
- }
-`;
-
-const StyledNameCell = styled.div`
- align-items: center;
- color: ${themeCssVariables.font.color.primary};
- display: flex;
- gap: ${themeCssVariables.spacing[2]};
- min-width: 0;
-`;
-
-const StyledHandle = styled.span`
- font-weight: ${themeCssVariables.font.weight.medium};
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-const StyledForwardingCell = styled.div`
- color: ${themeCssVariables.font.color.tertiary};
- font-family: monospace;
- font-size: ${themeCssVariables.font.size.sm};
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-const StyledFooter = styled.div`
- border-top: 1px solid ${themeCssVariables.border.color.light};
- display: flex;
- justify-content: flex-end;
- padding-top: ${themeCssVariables.spacing[2]};
-`;
-
export const SettingsWorkspaceEmailGroupSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
@@ -70,67 +18,27 @@ export const SettingsWorkspaceEmailGroupSection = () => {
);
return (
-
-
- {emailGroupChannels.length > 0 && (
-
-
-
- Source
-
-
- Forwarding address
-
-
-
- {emailGroupChannels.map((channel) => {
- const sourceHandle =
- channel.connectedAccount?.handle ?? channel.handle;
-
- return (
-
-
- navigateSettings(SettingsPath.EmailGroupChannelDetail, {
- messageChannelId: channel.id,
- })
- }
- >
-
-
-
- {sourceHandle}
-
-
-
-
- {channel.handle}
-
-
-
-
- );
- })}
-
-
- )}
-
-
-
+
+ title={t`Email Handles`}
+ description={t`Shared addresses your workspace uses to send and receive email.`}
+ items={emailGroupChannels}
+ columns={[
+ { label: t`Source`, Cell: SettingsWorkspaceEmailGroupSourceCell },
+ {
+ label: t`Forwarding address`,
+ Cell: SettingsWorkspaceEmailGroupForwardingCell,
+ },
+ ]}
+ gridAutoColumns="1fr 1fr"
+ onRowClick={(channel) =>
+ navigateSettings(SettingsPath.EmailGroupChannelDetail, {
+ messageChannelId: channel.id,
+ })
+ }
+ footerButtonLabel={t`Add email handle`}
+ onFooterButtonClick={() =>
+ navigateSettings(SettingsPath.NewEmailGroupChannel)
+ }
+ />
);
};
diff --git a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell.tsx b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell.tsx
new file mode 100644
index 0000000000..e21c615140
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell.tsx
@@ -0,0 +1,31 @@
+import { styled } from '@linaria/react';
+
+import { type MessageChannel } from '@/accounts/types/MessageChannel';
+import { IconMail, OverflowingTextWithTooltip } from 'twenty-ui/display';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+const StyledNameCell = styled.div`
+ align-items: center;
+ color: ${themeCssVariables.font.color.primary};
+ display: flex;
+ font-weight: ${themeCssVariables.font.weight.medium};
+ gap: ${themeCssVariables.spacing[2]};
+ min-width: 0;
+`;
+
+type SettingsWorkspaceEmailGroupSourceCellProps = {
+ item: MessageChannel;
+};
+
+export const SettingsWorkspaceEmailGroupSourceCell = ({
+ item,
+}: SettingsWorkspaceEmailGroupSourceCellProps) => {
+ const sourceHandle = item.connectedAccount?.handle;
+
+ return (
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection.tsx b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection.tsx
new file mode 100644
index 0000000000..e8cb1bdcbd
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection.tsx
@@ -0,0 +1,48 @@
+import { useLingui } from '@lingui/react/macro';
+import { useQuery } from '@apollo/client/react';
+
+import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
+import { SettingsEmailingDomainNameCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainNameCell';
+import { SettingsEmailingDomainStatusCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainStatusCell';
+import { SettingsPath } from 'twenty-shared/types';
+import {
+ type GetEmailingDomainsQuery,
+ GetEmailingDomainsDocument,
+} from '~/generated-metadata/graphql';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
+
+type EmailingDomain = GetEmailingDomainsQuery['getEmailingDomains'][0];
+
+export const SettingsWorkspaceEmailingDomainsSection = () => {
+ const { t } = useLingui();
+ const navigateSettings = useNavigateSettings();
+
+ const { data } = useQuery(GetEmailingDomainsDocument);
+ const emailingDomains = data?.getEmailingDomains ?? [];
+
+ return (
+
+ title={t`Emailing Domains`}
+ description={t`Verify domains so the workspace can send outbound email through them.`}
+ items={emailingDomains}
+ columns={[
+ { label: t`Domain`, Cell: SettingsEmailingDomainNameCell },
+ {
+ label: t`Status`,
+ align: 'right',
+ Cell: SettingsEmailingDomainStatusCell,
+ },
+ ]}
+ gridAutoColumns="1fr 1fr"
+ onRowClick={(emailingDomain) =>
+ navigateSettings(SettingsPath.EmailingDomainDetail, {
+ domainId: emailingDomain.id,
+ })
+ }
+ footerButtonLabel={t`Add emailing domain`}
+ onFooterButtonClick={() =>
+ navigateSettings(SettingsPath.NewEmailingDomain)
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
index 2dbdb6c1e2..89d393519e 100644
--- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx
@@ -5,6 +5,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsWorkspaceDomainCard } from '@/settings/domains/components/SettingsWorkspaceDomainCard';
import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace';
import { SettingsWorkspaceEmailGroupSection } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSection';
+import { SettingsWorkspaceEmailingDomainsSection } from '@/settings/workspace/components/SettingsWorkspaceEmailingDomainsSection';
import { NameField } from '@/settings/workspace/components/NameField';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
@@ -29,6 +30,7 @@ export const SettingsWorkspace = () => {
);
const showEmailGroupSection =
isEmailGroupEnabled && isEmailGroupFeatureEnabled;
+ const showEmailingDomainsSection = isEmailGroupFeatureEnabled;
return (
{
)}
{showEmailGroupSection && }
+ {showEmailingDomainsSection && (
+
+ )}
diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
index 3184ca67a8..3a8f2faa8f 100644
--- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
+++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab.tsx
@@ -42,7 +42,6 @@ import {
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
-import { SettingsEmailingDomains } from '~/pages/settings/emailing-domains/SettingsEmailingDomains';
const StyledButtonContainer = styled.div`
display: flex;
@@ -79,9 +78,6 @@ export const SettingsApplicationsDeveloperTab = () => {
const isPublicDomainEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
);
- const isEmailingDomainEnabled = useIsFeatureEnabled(
- FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
- );
const [marketplaceAppSearchTerm, setMarketplaceAppSearchTerm] = useState('');
@@ -205,16 +201,6 @@ export const SettingsApplicationsDeveloperTab = () => {
)}
- {isEmailingDomainEnabled && (
-
- )}
-
{isPublicDomainEnabled && (
{
+ const { t } = useLingui();
+ const navigateSettings = useNavigateSettings();
const { domainId } = useParams<{ domainId: string }>();
const { data, loading, error } = useQuery(
- GET_ALL_EMAILING_DOMAINS,
+ GetEmailingDomainsDocument,
{
skip: !domainId,
},
);
+ const { openModal } = useModal();
+ const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
+ const [deleteEmailingDomain, { loading: deleting }] = useMutation(
+ DeleteEmailingDomainDocument,
+ { refetchQueries: [GetEmailingDomainsDocument] },
+ );
+
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.id === domainId,
);
@@ -38,6 +58,22 @@ export const SettingsEmailingDomainDetail = () => {
);
}
+ const handleDelete = async () => {
+ try {
+ await deleteEmailingDomain({ variables: { id: emailingDomain.id } });
+ enqueueSuccessSnackBar({
+ message: t`Emailing domain deleted successfully`,
+ });
+ navigateSettings(SettingsPath.Workspace);
+ } catch (deleteError) {
+ enqueueErrorSnackBar({
+ ...(CombinedGraphQLErrors.is(deleteError)
+ ? { apolloError: deleteError }
+ : {}),
+ });
+ }
+ };
+
return (
{
},
{
children: Emailing Domains,
- href: getSettingsPath(SettingsPath.Applications),
+ href: getSettingsPath(SettingsPath.Workspace),
},
{ children: emailingDomain.domain },
]}
+ actionButton={
+
);
};
diff --git a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomains.tsx b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomains.tsx
deleted file mode 100644
index f23931b014..0000000000
--- a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsEmailingDomains.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Link, useNavigate } from 'react-router-dom';
-
-import { SettingsCard } from '@/settings/components/SettingsCard';
-import { SettingsListCard } from '@/settings/components/SettingsListCard';
-
-import { SettingsEmailingDomainRowDropdownMenu } from '@/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu';
-
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
-import { styled } from '@linaria/react';
-import { useLingui } from '@lingui/react/macro';
-import { SettingsPath } from 'twenty-shared/types';
-import { getSettingsPath } from 'twenty-shared/utils';
-import { IconMail, Status } from 'twenty-ui/display';
-import { useQuery } from '@apollo/client/react';
-import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
-import { dateLocaleState } from '~/localization/states/dateLocaleState';
-import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
-import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
-import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
-
-const StyledLinkContainer = styled.div`
- > a {
- text-decoration: none;
- }
-`;
-
-export const SettingsEmailingDomains = () => {
- const { t } = useLingui();
- const { localeCatalog } = useAtomStateValue(dateLocaleState);
- const navigate = useNavigate();
-
- const { data, loading: isLoading } = useQuery(GetEmailingDomainsDocument);
- const emailingDomains = data?.getEmailingDomains ?? [];
-
- const getItemDescription = (createdAt: string) => {
- const beautifyPastDateRelative = beautifyPastDateRelativeToNow(
- createdAt,
- localeCatalog,
- );
- return t`Added ${beautifyPastDateRelative}`;
- };
-
- return isLoading || !emailingDomains.length ? (
-
-
- } />
-
-
- ) : (
- <>
- domain ?? ''}
- getItemDescription={({ createdAt }) => getItemDescription(createdAt)}
- RowIcon={IconMail}
- onRowClick={(emailingDomain) => {
- navigate(
- getSettingsPath(SettingsPath.EmailingDomainDetail, {
- domainId: emailingDomain.id,
- }),
- );
- }}
- RowRightComponent={({ item: emailingDomain }) => (
- <>
-
-
- >
- )}
- hasFooter
- footerButtonLabel={t`Add Emailing Domain`}
- onFooterButtonClick={() =>
- navigate(getSettingsPath(SettingsPath.NewEmailingDomain))
- }
- />
- >
- );
-};
diff --git a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
index 8af3e3d1b2..fb9f892be8 100644
--- a/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
+++ b/packages/twenty-front/src/pages/settings/emailing-domains/SettingsNewEmailingDomain.tsx
@@ -114,7 +114,7 @@ export const SettingsNewEmailingDomain = () => {
title={t`New Emailing Domain`}
actionButton={
navigate(SettingsPath.Applications)}
+ onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave}
isSaveDisabled={!canSave}
/>
@@ -124,13 +124,9 @@ export const SettingsNewEmailingDomain = () => {
children: Workspace,
href: getSettingsPath(SettingsPath.Workspace),
},
- {
- children: Apps,
- href: getSettingsPath(SettingsPath.Applications),
- },
{
children: Emailing Domains,
- href: getSettingsPath(SettingsPath.Applications),
+ href: getSettingsPath(SettingsPath.Workspace),
},
{ children: New Emailing Domain },
]}
diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx
index 78eab4ab96..16615dbb4c 100644
--- a/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx
+++ b/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
-import { Navigate, useNavigate, useParams } from 'react-router-dom';
+import { useParams } from 'react-router-dom';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { useDeleteEmailGroupChannel } from '@/settings/accounts/hooks/useDeleteEmailGroupChannel';
@@ -14,13 +14,23 @@ import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconCopy, IconTrash } from 'twenty-ui/display';
+import { Loader } from 'twenty-ui/feedback';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
+import { NotFound } from '~/pages/not-found/NotFound';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_EMAIL_GROUP_MODAL_ID = 'delete-email-group-channel-modal';
+const StyledLoadingContainer = styled.div`
+ align-items: center;
+ display: flex;
+ height: 200px;
+ justify-content: center;
+`;
+
const StyledForwardingRow = styled.div`
display: flex;
flex-direction: row;
@@ -33,7 +43,7 @@ const StyledForwardingInputContainer = styled.div`
export const SettingsWorkspaceEmailGroupChannelDetail = () => {
const { t } = useLingui();
- const navigate = useNavigate();
+ const navigateSettings = useNavigateSettings();
const { messageChannelId } = useParams<{ messageChannelId: string }>();
const { channels, loading } = useMyMessageChannels();
const { copyToClipboard } = useCopyToClipboard();
@@ -43,7 +53,11 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
useDeleteEmailGroupChannel();
if (loading) {
- return null;
+ return (
+
+
+
+ );
}
const channel = channels.find(
@@ -52,20 +66,20 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
channel.type === MessageChannelType.EMAIL_GROUP,
);
- if (!isDefined(channel)) {
- return ;
+ if (!isDefined(channel) || !isDefined(channel.connectedAccount)) {
+ return ;
}
- const sourceHandle = channel.connectedAccount?.handle ?? channel.handle;
+ const sourceHandle = channel.connectedAccount.handle;
const forwardingAddress = channel.handle;
const handleDelete = async () => {
try {
await deleteEmailGroupChannel(channel.id);
- navigate(-1);
+ navigateSettings(SettingsPath.Workspace);
} catch {
enqueueErrorSnackBar({
- message: t`Failed to delete email group channel.`,
+ message: t`Failed to delete email handle.`,
});
}
};
@@ -100,7 +114,7 @@ export const SettingsWorkspaceEmailGroupChannelDetail = () => {
{
{
+ await queryRunner.query(
+ "CREATE TYPE \"core\".\"emailingDomain_tenantstatus_enum\" AS ENUM('ACTIVE', 'PAUSED', 'PERMANENTLY_SUSPENDED')",
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" ADD "tenantStatus" "core"."emailingDomain_tenantstatus_enum" NOT NULL DEFAULT \'ACTIVE\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" DROP CONSTRAINT "IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" ADD CONSTRAINT "IDX_EMAILING_DOMAIN_DOMAIN_UNIQUE" UNIQUE ("domain")',
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" DROP CONSTRAINT "IDX_EMAILING_DOMAIN_DOMAIN_UNIQUE"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" ADD CONSTRAINT "IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE" UNIQUE ("domain", "workspaceId")',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "core"."emailingDomain" DROP COLUMN "tenantStatus"',
+ );
+ await queryRunner.query(
+ 'DROP TYPE "core"."emailingDomain_tenantstatus_enum"',
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
index 97ed228082..d3971bb98d 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
@@ -42,6 +42,8 @@ import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/
import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled';
import { CreateSigningKeyTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table';
import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens';
+import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
+import { EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799000020000-emailing-domain-tenant-status-and-global-uniqueness';
import { EncryptApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable';
import { EncryptApplicationRegistrationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable';
import { EncryptSigningKeyPrivateKeysSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys';
@@ -50,7 +52,6 @@ import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upg
import { EncryptConnectionParametersSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-slow-1798000010000-encrypt-connection-parameters';
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
-import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata';
@@ -100,6 +101,8 @@ export const INSTANCE_COMMANDS = [
AddIsInternalMessagesImportEnabledFastInstanceCommand,
CreateSigningKeyTableFastInstanceCommand,
EncryptConnectedAccountTokensSlowInstanceCommand,
+ AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
+ EmailingDomainTenantStatusAndGlobalUniquenessFastInstanceCommand,
EncryptApplicationVariableSlowInstanceCommand,
EncryptApplicationRegistrationVariableSlowInstanceCommand,
EncryptSigningKeyPrivateKeysSlowInstanceCommand,
@@ -108,7 +111,6 @@ export const INSTANCE_COMMANDS = [
EncryptConnectionParametersSlowInstanceCommand,
AddSubFieldNameToViewSortFastInstanceCommand,
DropPostgresCredentialsTableFastInstanceCommand,
- AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
AddChannelSyncStageIndexesFastInstanceCommand,
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
AddSubFieldNameToIndexFieldMetadataFastInstanceCommand,
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant.ts
new file mode 100644
index 0000000000..9ddfa7075e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-event-bus-name.constant.ts
@@ -0,0 +1 @@
+export const AWS_SES_EVENT_BUS_NAME = 'default';
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant.ts
new file mode 100644
index 0000000000..b01807c9cf
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-mail-from-subdomain.constant.ts
@@ -0,0 +1 @@
+export const AWS_SES_MAIL_FROM_SUBDOMAIN = 'bounce';
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant.ts
new file mode 100644
index 0000000000..02bd67108b
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-marketing-topic-name.constant.ts
@@ -0,0 +1 @@
+export const AWS_SES_MARKETING_TOPIC_NAME = 'marketing';
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant.ts
new file mode 100644
index 0000000000..bcdbc199c6
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant.ts
@@ -0,0 +1 @@
+export const AWS_SES_RESOURCE_NAME_PREFIX = 'twenty-workspace';
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider.ts
index b80011882e..232e112666 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider.ts
@@ -27,11 +27,11 @@ export class AwsSesClientProvider {
'AWS_SES_SESSION_TOKEN',
);
- if (accessKeyId && secretAccessKey && sessionToken) {
+ if (accessKeyId && secretAccessKey) {
config.credentials = {
accessKeyId,
secretAccessKey,
- sessionToken,
+ ...(sessionToken ? { sessionToken } : {}),
};
}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-register-domain.service.spec.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-register-domain.service.spec.ts
new file mode 100644
index 0000000000..509abcead1
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-register-domain.service.spec.ts
@@ -0,0 +1,132 @@
+import {
+ CreateConfigurationSetCommand,
+ CreateConfigurationSetEventDestinationCommand,
+ CreateContactListCommand,
+ CreateTenantResourceAssociationCommand,
+ GetConfigurationSetCommand,
+ NotFoundException,
+ PutEmailIdentityMailFromAttributesCommand,
+} from '@aws-sdk/client-sesv2';
+
+import { type 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 { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
+import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
+
+describe('AwsSesRegisterDomainService', () => {
+ const config: AwsSesDriverConfig = {
+ driver: EmailingDomainDriver.AWS_SES,
+ region: 'us-east-1',
+ accountId: '123456789012',
+ };
+
+ const provisionInput = {
+ tenantName: 'twenty-workspace-ws1',
+ configurationSetName: 'twenty-workspace-ws1',
+ contactListName: 'twenty-workspace-ws1',
+ };
+
+ const buildNotFound = () =>
+ new NotFoundException({
+ $metadata: { httpStatusCode: 404 },
+ message: 'Configuration set not found.',
+ });
+
+ const setUp = () => {
+ const send = jest.fn();
+ const clientProvider = {
+ getSESClient: () => ({ send }),
+ } as unknown as AwsSesClientProvider;
+ const service = new AwsSesRegisterDomainService(clientProvider);
+
+ return { service, send };
+ };
+
+ describe('provisionWorkspaceResources', () => {
+ it('creates every workspace-scoped resource when the configuration set does not yet exist', async () => {
+ const { service, send } = setUp();
+
+ send.mockImplementation(async (command) => {
+ if (command instanceof GetConfigurationSetCommand) {
+ throw buildNotFound();
+ }
+
+ return {};
+ });
+
+ await service.provisionWorkspaceResources(provisionInput, config);
+
+ const commandTypes = send.mock.calls.map(
+ ([command]) => command.constructor.name,
+ );
+
+ expect(commandTypes).toEqual([
+ GetConfigurationSetCommand.name,
+ CreateConfigurationSetCommand.name,
+ CreateConfigurationSetEventDestinationCommand.name,
+ CreateContactListCommand.name,
+ CreateTenantResourceAssociationCommand.name,
+ ]);
+ });
+
+ it('issues no creates when the configuration set already exists', async () => {
+ const { service, send } = setUp();
+
+ send.mockResolvedValue({});
+
+ await service.provisionWorkspaceResources(provisionInput, config);
+
+ const commandTypes = send.mock.calls.map(
+ ([command]) => command.constructor.name,
+ );
+
+ expect(commandTypes).toEqual([GetConfigurationSetCommand.name]);
+ });
+
+ it('propagates non-NotFound AWS errors raised by the existence probe', async () => {
+ const { service, send } = setUp();
+ const fatalError = new Error('Boom');
+
+ send.mockImplementation(async (command) => {
+ if (command instanceof GetConfigurationSetCommand) {
+ throw fatalError;
+ }
+
+ return {};
+ });
+
+ await expect(
+ service.provisionWorkspaceResources(provisionInput, config),
+ ).rejects.toBe(fatalError);
+ });
+ });
+
+ describe('registerDomain', () => {
+ it('configures custom MAIL FROM using the bounce subdomain', async () => {
+ const { service, send } = setUp();
+
+ send.mockResolvedValue({});
+
+ await service.registerDomain('mail.example.com');
+
+ const commandTypes = send.mock.calls.map(
+ ([command]) => command.constructor.name,
+ );
+
+ expect(commandTypes).toEqual([
+ PutEmailIdentityMailFromAttributesCommand.name,
+ ]);
+
+ const mailFromCall = send.mock.calls.find(
+ ([command]) =>
+ command instanceof PutEmailIdentityMailFromAttributesCommand,
+ );
+
+ expect(mailFromCall?.[0].input).toMatchObject({
+ EmailIdentity: 'mail.example.com',
+ MailFromDomain: 'bounce.mail.example.com',
+ BehaviorOnMxFailure: 'USE_DEFAULT_VALUE',
+ });
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-send-email.service.spec.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-send-email.service.spec.ts
new file mode 100644
index 0000000000..36e810d138
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/__tests__/aws-ses-send-email.service.spec.ts
@@ -0,0 +1,113 @@
+import { SendEmailCommand } from '@aws-sdk/client-sesv2';
+
+import { type AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
+import { type AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
+import { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
+import {
+ EmailingDomainDriverException,
+ EmailingDomainDriverExceptionCode,
+} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
+
+describe('AwsSesSendEmailService', () => {
+ const baseInput = {
+ workspaceId: 'ws1',
+ domain: 'mail.example.com',
+ from: 'noreply@mail.example.com',
+ to: ['user@example.com'],
+ subject: 'Hello',
+ text: 'World',
+ };
+
+ const baseContext = {
+ tenantName: 'twenty-workspace-ws1',
+ configurationSetName: 'twenty-workspace-ws1',
+ contactListName: 'twenty-workspace-ws1',
+ };
+
+ const setUp = () => {
+ const send = jest.fn();
+ const clientProvider = {
+ getSESClient: () => ({ send }),
+ } as unknown as AwsSesClientProvider;
+ const handleErrorService = {
+ handleAwsSesError: jest.fn((error) => {
+ throw error;
+ }),
+ } as unknown as AwsSesHandleErrorService;
+ const service = new AwsSesSendEmailService(
+ clientProvider,
+ handleErrorService,
+ );
+
+ return { service, send, handleErrorService };
+ };
+
+ it('should call SendEmail with tenant, config set, and list management options', async () => {
+ const { service, send } = setUp();
+
+ send.mockResolvedValue({ MessageId: 'msg-1' });
+
+ const result = await service.sendEmail(baseInput, baseContext);
+
+ expect(result.messageId).toBe('msg-1');
+
+ const [command] = send.mock.calls[0];
+
+ expect(command).toBeInstanceOf(SendEmailCommand);
+ expect(command.input).toMatchObject({
+ FromEmailAddress: 'noreply@mail.example.com',
+ Destination: { ToAddresses: ['user@example.com'] },
+ ConfigurationSetName: 'twenty-workspace-ws1',
+ TenantName: 'twenty-workspace-ws1',
+ ListManagementOptions: {
+ ContactListName: 'twenty-workspace-ws1',
+ TopicName: 'marketing',
+ },
+ });
+ expect(command.input.EmailTags).toEqual(
+ expect.arrayContaining([
+ { Name: 'workspace', Value: 'ws1' },
+ { Name: 'domain', Value: 'mail.example.com' },
+ ]),
+ );
+ });
+
+ it('should throw when SES returns no MessageId', async () => {
+ const { service, send } = setUp();
+
+ send.mockResolvedValue({});
+
+ await expect(service.sendEmail(baseInput, baseContext)).rejects.toThrow(
+ EmailingDomainDriverException,
+ );
+ });
+
+ it('should reject empty recipient list before calling SES', async () => {
+ const { service, send } = setUp();
+
+ await expect(
+ service.sendEmail({ ...baseInput, to: [] }, baseContext),
+ ).rejects.toMatchObject({
+ code: EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
+ });
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it('should route AWS errors through the error handler', async () => {
+ const { service, send, handleErrorService } = setUp();
+ const awsError = Object.assign(new Error('Rejected'), {
+ name: 'MessageRejected',
+ $metadata: { httpStatusCode: 400 },
+ });
+
+ send.mockRejectedValue(awsError);
+
+ await expect(service.sendEmail(baseInput, baseContext)).rejects.toBe(
+ awsError,
+ );
+ expect(handleErrorService.handleAwsSesError).toHaveBeenCalledWith(
+ awsError,
+ 'sendEmail',
+ );
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service.ts
index 2cdc97da58..a9d23328e3 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service.ts
@@ -1,24 +1,37 @@
import { Logger } from '@nestjs/common';
import {
+ AlreadyExistsException,
CreateEmailIdentityCommand,
CreateTenantCommand,
CreateTenantResourceAssociationCommand,
+ DeleteConfigurationSetCommand,
+ DeleteContactListCommand,
+ DeleteEmailIdentityCommand,
+ DeleteTenantCommand,
+ DeleteTenantResourceAssociationCommand,
GetEmailIdentityCommand,
+ NotFoundException,
PutEmailIdentityDkimAttributesCommand,
} from '@aws-sdk/client-sesv2';
import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
import {
- type DomainStatusInput,
- type DomainVerificationInput,
type EmailingDomainDriverInterface,
+ 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 { 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';
+import { AwsSesRegisterDomainService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service';
import { type AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
-import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+import { type AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
+import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { type VerificationRecordDTO } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
export class AwsSesDriver implements EmailingDomainDriverInterface {
@@ -28,17 +41,17 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
private readonly config: AwsSesDriverConfig,
private readonly awsSesClientProvider: AwsSesClientProvider,
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
+ private readonly awsSesRegisterDomainService: AwsSesRegisterDomainService,
+ private readonly awsSesSendEmailService: AwsSesSendEmailService,
) {}
async verifyDomain(
- input: DomainVerificationInput,
+ input: EmailingDomainResourceInput,
): Promise {
try {
this.logger.log(`Starting domain verification for: ${input.domain}`);
- const tenantName = this.generateTenantName(input.workspaceId);
-
- await this.ensureTenantExists(tenantName);
+ const tenantName = this.buildTenantName(input.workspaceId);
const { isVerified, verificationRecords } =
await this.createOrUpdateEmailIdentity(input.domain, tenantName);
@@ -51,7 +64,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
status: isVerified
? EmailingDomainStatus.VERIFIED
: EmailingDomainStatus.PENDING,
- verifiedAt: isVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
@@ -61,7 +73,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
}
async getDomainStatus(
- input: DomainStatusInput,
+ input: EmailingDomainResourceInput,
): Promise {
try {
this.logger.log(`Getting domain status for: ${input.domain}`);
@@ -75,7 +87,6 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
const identityResponse = await sesClient.send(getIdentityCommand);
const status = this.determineVerificationStatus(identityResponse);
- const isFullyVerified = status === EmailingDomainStatus.VERIFIED;
const verificationRecords = this.buildVerificationRecords(
input.domain,
identityResponse.DkimAttributes?.Tokens || [],
@@ -83,14 +94,12 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return {
status,
- verifiedAt: isFullyVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
- if (error.name === 'NotFoundException') {
+ if (error instanceof NotFoundException) {
return {
status: EmailingDomainStatus.FAILED,
- verifiedAt: null,
verificationRecords: [],
};
}
@@ -102,8 +111,109 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
}
}
- private generateTenantName(workspaceId: string): string {
- return `twenty-workspace-${workspaceId}`;
+ async provisionWorkspace(workspaceId: string): Promise {
+ const tenantName = this.buildTenantName(workspaceId);
+
+ await this.ensureTenantExists(tenantName);
+
+ await this.awsSesRegisterDomainService.provisionWorkspaceResources(
+ {
+ tenantName,
+ configurationSetName: this.buildConfigurationSetName(workspaceId),
+ contactListName: this.buildContactListName(workspaceId),
+ },
+ this.config,
+ );
+ }
+
+ async registerDomain(input: EmailingDomainResourceInput): Promise {
+ await this.awsSesRegisterDomainService.registerDomain(input.domain);
+ }
+
+ async sendEmail(
+ input: EmailingDomainSendEmailInput,
+ ): Promise {
+ return this.awsSesSendEmailService.sendEmail(input, {
+ tenantName: this.buildTenantName(input.workspaceId),
+ configurationSetName: this.buildConfigurationSetName(input.workspaceId),
+ contactListName: this.buildContactListName(input.workspaceId),
+ });
+ }
+
+ async cleanupDomain(input: EmailingDomainResourceInput): Promise {
+ const sesClient = this.awsSesClientProvider.getSESClient();
+ const tenantName = this.buildTenantName(input.workspaceId);
+ const identityArn = `arn:aws:ses:${this.config.region}:${this.config.accountId}:identity/${input.domain}`;
+
+ await sesClient
+ .send(
+ new DeleteTenantResourceAssociationCommand({
+ TenantName: tenantName,
+ ResourceArn: identityArn,
+ }),
+ )
+ .catch((error) => {
+ if (!(error instanceof NotFoundException)) throw error;
+ });
+
+ await sesClient
+ .send(new DeleteEmailIdentityCommand({ EmailIdentity: input.domain }))
+ .catch((error) => {
+ if (!(error instanceof NotFoundException)) throw error;
+ });
+ }
+
+ async deprovisionWorkspace(workspaceId: string): Promise {
+ 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
+ .send(
+ new DeleteTenantResourceAssociationCommand({
+ TenantName: tenantName,
+ ResourceArn: configurationSetArn,
+ }),
+ )
+ .catch((error) => {
+ if (!(error instanceof NotFoundException)) throw error;
+ });
+
+ await sesClient
+ .send(
+ new DeleteConfigurationSetCommand({
+ ConfigurationSetName: configurationSetName,
+ }),
+ )
+ .catch((error) => {
+ 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) => {
+ if (!(error instanceof NotFoundException)) throw error;
+ });
+ }
+
+ private buildTenantName(workspaceId: string): string {
+ return `${AWS_SES_RESOURCE_NAME_PREFIX}-${workspaceId}`;
+ }
+
+ private buildConfigurationSetName(workspaceId: string): string {
+ 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 {
@@ -113,7 +223,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
await sesClient.send(new CreateTenantCommand({ TenantName: tenantName }));
this.logger.log(`Created tenant: ${tenantName}`);
} catch (error) {
- if (error.name === 'AlreadyExistsException') {
+ if (error instanceof AlreadyExistsException) {
this.logger.log(`Tenant already exists: ${tenantName}`);
return;
@@ -143,13 +253,11 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
existingIdentity.DkimAttributes?.Tokens || [],
);
- if (!isVerified) {
- await this.associateResourceWithTenant(domain, tenantName);
- }
+ await this.associateResourceWithTenant(domain, tenantName);
return { isVerified, verificationRecords };
} catch (error) {
- if (error.name === 'NotFoundException') {
+ if (error instanceof NotFoundException) {
return await this.createNewEmailIdentity(domain, tenantName);
}
throw error;
@@ -201,7 +309,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
);
this.logger.log(`Associated domain ${domain} with tenant ${tenantName}`);
} catch (error) {
- if (error.name === 'AlreadyExistsException') {
+ if (error instanceof AlreadyExistsException) {
this.logger.log(
`Domain ${domain} already associated with tenant ${tenantName}`,
);
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service.ts
new file mode 100644
index 0000000000..2fe146eab3
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-register-domain.service.ts
@@ -0,0 +1,140 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import {
+ CreateConfigurationSetCommand,
+ CreateConfigurationSetEventDestinationCommand,
+ CreateContactListCommand,
+ CreateTenantResourceAssociationCommand,
+ GetConfigurationSetCommand,
+ NotFoundException,
+ PutEmailIdentityMailFromAttributesCommand,
+ type SESv2Client,
+} from '@aws-sdk/client-sesv2';
+import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
+
+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()
+export class AwsSesRegisterDomainService {
+ private readonly logger = new Logger(AwsSesRegisterDomainService.name);
+
+ constructor(private readonly awsSesClientProvider: AwsSesClientProvider) {}
+
+ async provisionWorkspaceResources(
+ input: ProvisionWorkspaceInput,
+ config: AwsSesDriverConfig,
+ ): Promise {
+ const sesClient = this.awsSesClientProvider.getSESClient();
+
+ const isAlreadyProvisioned = await this.isWorkspaceProvisioned(
+ sesClient,
+ input.configurationSetName,
+ );
+
+ if (isAlreadyProvisioned) {
+ return;
+ }
+
+ const eventBusArn = `arn:aws:events:${config.region}:${config.accountId}:event-bus/${AWS_SES_EVENT_BUS_NAME}`;
+ const configurationSetArn = `arn:aws:ses:${config.region}:${config.accountId}:configuration-set/${input.configurationSetName}`;
+
+ await sesClient.send(
+ new CreateConfigurationSetCommand({
+ ConfigurationSetName: input.configurationSetName,
+ ReputationOptions: { ReputationMetricsEnabled: true },
+ SendingOptions: { SendingEnabled: true },
+ SuppressionOptions: { SuppressedReasons: ['BOUNCE', 'COMPLAINT'] },
+ Tags: [{ Key: 'managed-by', Value: 'twenty' }],
+ }),
+ );
+
+ await sesClient.send(
+ new CreateConfigurationSetEventDestinationCommand({
+ ConfigurationSetName: input.configurationSetName,
+ EventDestinationName: 'twenty-eventbridge',
+ EventDestination: {
+ Enabled: true,
+ MatchingEventTypes: [
+ 'SEND',
+ 'DELIVERY',
+ 'BOUNCE',
+ 'COMPLAINT',
+ 'REJECT',
+ 'RENDERING_FAILURE',
+ 'DELIVERY_DELAY',
+ 'SUBSCRIPTION',
+ ],
+ EventBridgeDestination: { EventBusArn: eventBusArn },
+ },
+ }),
+ );
+
+ 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' }],
+ }),
+ );
+
+ await sesClient.send(
+ new CreateTenantResourceAssociationCommand({
+ TenantName: input.tenantName,
+ ResourceArn: configurationSetArn,
+ }),
+ );
+
+ this.logger.log(
+ `Provisioned workspace resources for tenant ${input.tenantName}`,
+ );
+ }
+
+ async registerDomain(domain: string): Promise {
+ const sesClient = this.awsSesClientProvider.getSESClient();
+
+ await sesClient.send(
+ new PutEmailIdentityMailFromAttributesCommand({
+ EmailIdentity: domain,
+ MailFromDomain: `${AWS_SES_MAIL_FROM_SUBDOMAIN}.${domain}`,
+ BehaviorOnMxFailure: 'USE_DEFAULT_VALUE',
+ }),
+ );
+
+ this.logger.log(`Registered MAIL FROM for domain ${domain}`);
+ }
+
+ private async isWorkspaceProvisioned(
+ sesClient: SESv2Client,
+ configurationSetName: string,
+ ): Promise {
+ try {
+ await sesClient.send(
+ new GetConfigurationSetCommand({
+ ConfigurationSetName: configurationSetName,
+ }),
+ );
+
+ return true;
+ } catch (error) {
+ if (error instanceof NotFoundException) {
+ return false;
+ }
+ throw error;
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service.ts
new file mode 100644
index 0000000000..6f2530a5d6
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service.ts
@@ -0,0 +1,108 @@
+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 { 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 {
+ EmailingDomainDriverException,
+ EmailingDomainDriverExceptionCode,
+} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
+
+type SendEmailContext = {
+ tenantName: string;
+ configurationSetName: string;
+ contactListName: string;
+};
+
+@Injectable()
+export class AwsSesSendEmailService {
+ private readonly logger = new Logger(AwsSesSendEmailService.name);
+
+ constructor(
+ private readonly awsSesClientProvider: AwsSesClientProvider,
+ private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
+ ) {}
+
+ async sendEmail(
+ input: EmailingDomainSendEmailInput,
+ context: SendEmailContext,
+ ): Promise {
+ if (!isNonEmptyArray(input.to)) {
+ throw new EmailingDomainDriverException(
+ 'sendEmail requires at least one recipient',
+ EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
+ );
+ }
+
+ try {
+ const sesClient = this.awsSesClientProvider.getSESClient();
+
+ const response = await sesClient.send(
+ new SendEmailCommand({
+ FromEmailAddress: input.from,
+ Destination: {
+ ToAddresses: input.to,
+ CcAddresses: input.cc,
+ BccAddresses: input.bcc,
+ },
+ ReplyToAddresses: input.replyTo,
+ Content: {
+ Simple: {
+ Subject: { Data: input.subject, Charset: 'UTF-8' },
+ Body: {
+ Text: { Data: input.text, Charset: 'UTF-8' },
+ Html: isDefined(input.html)
+ ? { Data: input.html, Charset: 'UTF-8' }
+ : undefined,
+ },
+ Attachments: isNonEmptyArray(input.attachments)
+ ? input.attachments.map((attachment) => ({
+ FileName: attachment.filename,
+ RawContent: attachment.content,
+ ContentType: attachment.contentType,
+ ContentDisposition: 'ATTACHMENT',
+ }))
+ : undefined,
+ },
+ },
+ 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 },
+ ],
+ }),
+ );
+
+ if (!isDefined(response.MessageId)) {
+ throw new EmailingDomainDriverException(
+ 'SES returned no MessageId',
+ EmailingDomainDriverExceptionCode.UNKNOWN,
+ );
+ }
+
+ this.logger.log(
+ `Sent email ${response.MessageId} from ${input.from} (tenant ${context.tenantName})`,
+ );
+
+ return { messageId: response.MessageId };
+ } catch (error) {
+ if (error instanceof EmailingDomainDriverException) {
+ throw error;
+ }
+ this.awsSesHandleErrorService.handleAwsSesError(error, 'sendEmail');
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts
index c928c3f850..1929fed56c 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory.ts
@@ -4,9 +4,11 @@ import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain
import { type EmailingDomainDriverInterface } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
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 { AwsSesDriver } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
-import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+import { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
+import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/services/config-group-hash.service';
@@ -19,6 +21,8 @@ export class EmailingDomainDriverFactory extends DriverFactoryBase;
+ deprovisionWorkspace(workspaceId: string): Promise;
verifyDomain(
- input: DomainVerificationInput,
+ input: EmailingDomainResourceInput,
): Promise;
getDomainStatus(
- input: DomainStatusInput,
+ input: EmailingDomainResourceInput,
): Promise;
+ registerDomain(input: EmailingDomainResourceInput): Promise;
+ cleanupDomain(input: EmailingDomainResourceInput): Promise;
+ sendEmail(
+ input: EmailingDomainSendEmailInput,
+ ): Promise;
}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type.ts
new file mode 100644
index 0000000000..147b8f2353
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type.ts
@@ -0,0 +1,3 @@
+export enum EmailingDomainDriver {
+ AWS_SES = 'AWS_SES',
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type.ts
similarity index 70%
rename from packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain.ts
rename to packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type.ts
index b32f2a87dd..a00d20e177 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type.ts
@@ -1,7 +1,3 @@
-export enum EmailingDomainDriver {
- AWS_SES = 'AWS_SES',
-}
-
export enum EmailingDomainStatus {
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type.ts
new file mode 100644
index 0000000000..9eff96727d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type.ts
@@ -0,0 +1,5 @@
+export enum EmailingDomainTenantStatus {
+ ACTIVE = 'ACTIVE',
+ PAUSED = 'PAUSED',
+ PERMANENTLY_SUSPENDED = 'PERMANENTLY_SUSPENDED',
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/send-email.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/send-email.ts
new file mode 100644
index 0000000000..e1e38d12e4
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/drivers/types/send-email.ts
@@ -0,0 +1,26 @@
+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;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto.ts
index 8023e40273..5a9c480a24 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto.ts
@@ -3,10 +3,8 @@ 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 {
- EmailingDomainDriver,
- EmailingDomainStatus,
-} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
+import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { VerificationRecordDTO } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
registerEnumType(EmailingDomainDriver, {
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto.ts
new file mode 100644
index 0000000000..229ed2fb44
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto.ts
@@ -0,0 +1,7 @@
+import { Field, ObjectType } from '@nestjs/graphql';
+
+@ObjectType('SendEmailViaDomainOutput')
+export class SendEmailViaDomainOutputDTO {
+ @Field(() => String)
+ messageId: string;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input.ts
new file mode 100644
index 0000000000..17033084de
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input.ts
@@ -0,0 +1,59 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import {
+ ArrayMinSize,
+ IsArray,
+ IsEmail,
+ IsOptional,
+ IsString,
+ MinLength,
+} from 'class-validator';
+
+@InputType()
+export class SendEmailViaDomainInput {
+ @Field(() => String)
+ @IsString()
+ emailingDomainId: string;
+
+ @Field(() => [String])
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsEmail({}, { each: true })
+ to: string[];
+
+ @Field(() => [String], { nullable: true })
+ @IsOptional()
+ @IsArray()
+ @IsEmail({}, { each: true })
+ cc?: string[];
+
+ @Field(() => [String], { nullable: true })
+ @IsOptional()
+ @IsArray()
+ @IsEmail({}, { each: true })
+ bcc?: string[];
+
+ @Field(() => String)
+ @IsString()
+ @MinLength(1)
+ subject: string;
+
+ @Field(() => String)
+ @IsString()
+ text: string;
+
+ @Field(() => String, { nullable: true })
+ @IsOptional()
+ @IsString()
+ html?: string;
+
+ @Field(() => String)
+ @IsEmail()
+ from: string;
+
+ @Field(() => [String], { nullable: true })
+ @IsOptional()
+ @IsArray()
+ @IsEmail({}, { each: true })
+ replyTo?: string[];
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.entity.ts
index eab202e11d..db3d558861 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.entity.ts
@@ -9,19 +9,15 @@ import {
UpdateDateColumn,
} from 'typeorm';
-import {
- EmailingDomainDriver,
- EmailingDomainStatus,
-} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
+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 { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'emailingDomain', schema: 'core' })
@ObjectType('EmailingDomain')
-@Unique('IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE', [
- 'domain',
- 'workspaceId',
-])
+@Unique('IDX_EMAILING_DOMAIN_DOMAIN_UNIQUE', ['domain'])
export class EmailingDomainEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -55,4 +51,12 @@ export class EmailingDomainEntity extends WorkspaceRelatedEntity {
@Column({ type: 'timestamptz', nullable: true })
verifiedAt: Date | null;
+
+ @Column({
+ type: 'enum',
+ enum: Object.values(EmailingDomainTenantStatus),
+ default: EmailingDomainTenantStatus.ACTIVE,
+ nullable: false,
+ })
+ tenantStatus: EmailingDomainTenantStatus;
}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts
index a6a0d94088..034e2826f2 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts
@@ -4,26 +4,36 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.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';
+import { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver';
+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 { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@Module({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature([EmailingDomainEntity]),
+ FeatureFlagModule,
PermissionsModule,
],
- exports: [EmailingDomainService],
+ exports: [EmailingDomainService, EmailingDomainTenantStatusService],
providers: [
EmailingDomainService,
+ EmailingDomainTenantStatusService,
EmailingDomainResolver,
EmailingDomainDriverFactory,
+ EmailingDomainWorkspaceCleanupJob,
AwsSesClientProvider,
AwsSesHandleErrorService,
+ AwsSesRegisterDomainService,
+ AwsSesSendEmailService,
provideWorkspaceScopedRepository(EmailingDomainEntity),
],
})
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts
index b262f2c946..6326fb5a7c 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts
@@ -2,19 +2,27 @@ import { UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
+import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
-import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
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';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
+import {
+ FeatureFlagGuard,
+ RequireFeatureFlag,
+} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(
WorkspaceAuthGuard,
+ FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@UsePipes(ResolverValidationPipe)
@@ -23,6 +31,7 @@ export class EmailingDomainResolver {
constructor(private readonly emailingDomainService: EmailingDomainService) {}
@Mutation(() => EmailingDomainDTO)
+ @RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('driver') driver: EmailingDomainDriver,
@@ -39,6 +48,7 @@ export class EmailingDomainResolver {
}
@Mutation(() => Boolean)
+ @RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async deleteEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
@@ -49,6 +59,7 @@ export class EmailingDomainResolver {
}
@Mutation(() => EmailingDomainDTO)
+ @RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async verifyEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
@@ -62,7 +73,24 @@ export class EmailingDomainResolver {
return emailingDomain;
}
+ @Mutation(() => SendEmailViaDomainOutputDTO)
+ @RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
+ async sendEmailViaEmailingDomain(
+ @Args('input') input: SendEmailViaDomainInput,
+ @AuthWorkspace() currentWorkspace: WorkspaceEntity,
+ ): Promise {
+ 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(
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise {
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job.ts
new file mode 100644
index 0000000000..89a7640913
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job.ts
@@ -0,0 +1,28 @@
+import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
+import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
+import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+
+export type EmailingDomainWorkspaceCleanupJobData = {
+ workspaceId: string;
+};
+
+@Processor(MessageQueue.deleteCascadeQueue)
+export class EmailingDomainWorkspaceCleanupJob {
+ constructor(private readonly emailingDomainService: EmailingDomainService) {}
+
+ @Process(EmailingDomainWorkspaceCleanupJob.name)
+ async handle(data: EmailingDomainWorkspaceCleanupJobData): Promise {
+ const { workspaceId } = data;
+
+ try {
+ await this.emailingDomainService.cleanupAllEmailingDomainsForWorkspace(
+ workspaceId,
+ );
+ } catch (error) {
+ throw new Error(
+ `[${EmailingDomainWorkspaceCleanupJob.name}] Cannot cleanup emailing domains - ${workspaceId} - ${error?.message || error}`,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/__tests__/emailing-domain.service.spec.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/__tests__/emailing-domain.service.spec.ts
new file mode 100644
index 0000000000..ffc093cba6
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/__tests__/emailing-domain.service.spec.ts
@@ -0,0 +1,97 @@
+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 =>
+ ({
+ 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;
+ 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,
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service.ts
new file mode 100644
index 0000000000..6cd09e9c31
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service.ts
@@ -0,0 +1,35 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import { Not } from 'typeorm';
+
+import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
+import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.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 EmailingDomainTenantStatusService {
+ private readonly logger = new Logger(EmailingDomainTenantStatusService.name);
+
+ constructor(
+ @InjectWorkspaceScopedRepository(EmailingDomainEntity)
+ private readonly emailingDomainRepository: WorkspaceScopedRepository,
+ ) {}
+
+ async setTenantStatusForWorkspace(
+ workspaceId: string,
+ tenantStatus: EmailingDomainTenantStatus,
+ ): Promise {
+ const { affected } = await this.emailingDomainRepository.update(
+ workspaceId,
+ {
+ tenantStatus: Not(EmailingDomainTenantStatus.PERMANENTLY_SUSPENDED),
+ },
+ { tenantStatus },
+ );
+
+ this.logger.log(
+ `Workspace ${workspaceId}: ${affected ?? 0} domain(s) -> ${tenantStatus}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts
index 64d94f6a9d..641508c29a 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts
@@ -1,16 +1,25 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
-import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import {
- EmailingDomainDriver,
- EmailingDomainStatus,
-} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
+ 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 { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
+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 { 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);
+
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository,
@@ -19,30 +28,47 @@ export class EmailingDomainService {
async createEmailingDomain(
domain: string,
- driver: EmailingDomainDriver,
+ driverType: EmailingDomainDriver,
workspace: WorkspaceEntity,
): Promise {
- const existingDomain = await this.emailingDomainRepository.findOne(
+ const existingEmailingDomain = await this.emailingDomainRepository.findOne(
workspace.id,
{
where: { domain },
},
);
- if (existingDomain) {
- throw new Error('Emailing domain already exists for this workspace');
+ if (existingEmailingDomain) {
+ throw new EmailingDomainDriverException(
+ 'Emailing domain already exists for this workspace',
+ EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
+ );
}
- const driverInstance = this.emailingDomainDriverFactory.getCurrentDriver();
- const verificationResult = await driverInstance.verifyDomain({
+ const emailingDomainDriver =
+ this.emailingDomainDriverFactory.getCurrentDriver();
+
+ await emailingDomainDriver.provisionWorkspace(workspace.id);
+
+ const verificationResult = await emailingDomainDriver.verifyDomain({
domain,
workspaceId: workspace.id,
});
+ await emailingDomainDriver.registerDomain({
+ domain,
+ workspaceId: workspace.id,
+ });
+
+ const isVerifiedOnCreation =
+ verificationResult.status === EmailingDomainStatus.VERIFIED;
+
return this.emailingDomainRepository.save(workspace.id, {
domain,
- driver,
- ...verificationResult,
+ driver: driverType,
+ status: verificationResult.status,
+ verificationRecords: verificationResult.verificationRecords,
+ verifiedAt: isVerifiedOnCreation ? new Date() : null,
});
}
@@ -50,22 +76,31 @@ export class EmailingDomainService {
workspace: WorkspaceEntity,
emailingDomainId: string,
): Promise {
- const emailingDomain = await this.emailingDomainRepository.findOne(
+ const emailingDomain = await this.findEmailingDomainByIdOrThrow(
workspace.id,
- {
- where: { id: emailingDomainId },
- },
+ emailingDomainId,
);
- if (!emailingDomain) {
- throw new Error('Emailing domain not found');
- }
-
+ await this.deleteRemoteEmailingDomain(emailingDomain);
await this.emailingDomainRepository.delete(workspace.id, {
id: emailingDomain.id,
});
}
+ async cleanupAllEmailingDomainsForWorkspace(
+ workspaceId: string,
+ ): Promise {
+ const emailingDomains =
+ await this.emailingDomainRepository.find(workspaceId);
+
+ for (const emailingDomain of emailingDomains) {
+ await this.deleteRemoteEmailingDomain(emailingDomain);
+ }
+
+ await this.deprovisionRemoteWorkspace(workspaceId);
+ await this.emailingDomainRepository.delete(workspaceId, {});
+ }
+
async getEmailingDomains(
workspace: WorkspaceEntity,
): Promise {
@@ -74,88 +109,127 @@ export class EmailingDomainService {
});
}
- async getEmailingDomain(
- workspace: WorkspaceEntity,
- emailingDomainId: string,
- ): Promise {
- return this.emailingDomainRepository.findOne(workspace.id, {
- where: { id: emailingDomainId },
- });
- }
-
async verifyEmailingDomain(
workspace: WorkspaceEntity,
emailingDomainId: string,
): Promise {
- const emailingDomain = await this.getEmailingDomain(
- workspace,
+ const emailingDomain = await this.findEmailingDomainByIdOrThrow(
+ workspace.id,
emailingDomainId,
);
- if (!emailingDomain) {
- throw new Error('Emailing domain not found');
- }
+ const emailingDomainDriver =
+ this.emailingDomainDriverFactory.getCurrentDriver();
- if (emailingDomain.status === EmailingDomainStatus.VERIFIED) {
- throw new Error('Emailing domain is already verified');
- }
-
- const driver = this.emailingDomainDriverFactory.getCurrentDriver();
- const verificationResult = await driver.verifyDomain({
+ const verificationResult = await emailingDomainDriver.verifyDomain({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
- return this.emailingDomainRepository.save(workspace.id, {
- ...emailingDomain,
- ...verificationResult,
- });
- }
-
- async syncEmailingDomain(
- workspace: WorkspaceEntity,
- emailingDomainId: string,
- ): Promise {
- const emailingDomain = await this.getEmailingDomain(
- workspace,
- emailingDomainId,
- );
-
- if (!emailingDomain) {
- throw new Error('Emailing domain not found');
- }
+ const hasJustBecomeVerified =
+ emailingDomain.status !== EmailingDomainStatus.VERIFIED &&
+ verificationResult.status === EmailingDomainStatus.VERIFIED;
await this.emailingDomainRepository.update(
workspace.id,
- { id: emailingDomainId },
+ { id: emailingDomain.id },
{
- verificationRecords: emailingDomain.verificationRecords,
- status: EmailingDomainStatus.PENDING,
+ status: verificationResult.status,
+ verificationRecords: verificationResult.verificationRecords,
+ ...(hasJustBecomeVerified ? { verifiedAt: new Date() } : {}),
},
);
+ return this.emailingDomainRepository.findOneOrFail(workspace.id, {
+ where: { id: emailingDomain.id },
+ });
+ }
+
+ async sendEmail(
+ workspaceId: string,
+ emailingDomainId: string,
+ emailContent: EmailingDomainEmailContent,
+ ): Promise {
+ const emailingDomain = await this.findEmailingDomainByIdOrThrow(
+ workspaceId,
+ emailingDomainId,
+ );
+
+ 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,
+ });
+ }
+
+ private async findEmailingDomainByIdOrThrow(
+ workspaceId: string,
+ emailingDomainId: string,
+ ): Promise {
+ const emailingDomain = await this.emailingDomainRepository.findOne(
+ workspaceId,
+ {
+ where: { id: emailingDomainId },
+ },
+ );
+
+ if (!emailingDomain) {
+ throw new EmailingDomainDriverException(
+ 'Emailing domain not found',
+ EmailingDomainDriverExceptionCode.NOT_FOUND,
+ );
+ }
+
+ return emailingDomain;
+ }
+
+ private async deleteRemoteEmailingDomain(
+ emailingDomain: EmailingDomainEntity,
+ ): Promise {
try {
- const driver = this.emailingDomainDriverFactory.getCurrentDriver();
- const statusResult = await driver.getDomainStatus({
+ await this.emailingDomainDriverFactory.getCurrentDriver().cleanupDomain({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
-
- return this.emailingDomainRepository.save(workspace.id, {
- ...emailingDomain,
- ...statusResult,
- });
} catch (error) {
- await this.emailingDomainRepository.update(
- workspace.id,
- { id: emailingDomainId },
- {
- verificationRecords: emailingDomain.verificationRecords,
- status: emailingDomain.status,
- },
+ this.logger.warn(
+ `Remote cleanup for emailing domain ${emailingDomain.domain} (workspace ${emailingDomain.workspaceId}) failed: ${error}`,
);
+ }
+ }
- throw error;
+ private async deprovisionRemoteWorkspace(workspaceId: string): Promise {
+ try {
+ await this.emailingDomainDriverFactory
+ .getCurrentDriver()
+ .deprovisionWorkspace(workspaceId);
+ } catch (error) {
+ this.logger.warn(
+ `Remote deprovision for emailing domain workspace ${workspaceId} failed: ${error}`,
+ );
}
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
index 259b77ca01..bf64d625b1 100644
--- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
@@ -13,6 +13,7 @@ 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 { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { GenerateSdkClientJob } from 'src/engine/core-modules/sdk-client/jobs/generate-sdk-client.job';
@@ -79,6 +80,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
AiChatModule,
LogicFunctionModule,
EnterpriseModule,
+ EmailingDomainModule,
],
providers: [
CleanSuspendedWorkspacesJob,
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter.ts
new file mode 100644
index 0000000000..024e97a103
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/filters/messaging-webhook-api-exception.filter.ts
@@ -0,0 +1,29 @@
+import {
+ type ArgumentsHost,
+ Catch,
+ type ExceptionFilter,
+} from '@nestjs/common';
+
+import { type Response } from 'express';
+
+import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
+import { MessagingWebhookException } from 'src/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();
+
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ getMessagingWebhookExceptionStatusCode(exception),
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum.ts
new file mode 100644
index 0000000000..93de20c2f5
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook-exception-code.enum.ts
@@ -0,0 +1,9 @@
+export enum MessagingWebhookExceptionCode {
+ MESSAGING_WEBHOOK_MISSING_REQUEST_BODY = 'MESSAGING_WEBHOOK_MISSING_REQUEST_BODY',
+ MESSAGING_WEBHOOK_INVALID_PAYLOAD = 'MESSAGING_WEBHOOK_INVALID_PAYLOAD',
+ MESSAGING_WEBHOOK_FORBIDDEN_TOPIC = 'MESSAGING_WEBHOOK_FORBIDDEN_TOPIC',
+ MESSAGING_WEBHOOK_INVALID_SIGNATURE = 'MESSAGING_WEBHOOK_INVALID_SIGNATURE',
+ MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL = 'MESSAGING_WEBHOOK_INVALID_SUBSCRIBE_URL',
+ MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED = 'MESSAGING_WEBHOOK_SUBSCRIPTION_CONFIRMATION_FAILED',
+ MESSAGING_WEBHOOK_UNHANDLED_ERROR = 'MESSAGING_WEBHOOK_UNHANDLED_ERROR',
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook.exception.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook.exception.ts
new file mode 100644
index 0000000000..72e9e78b41
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhook.exception.ts
@@ -0,0 +1,39 @@
+import { type MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import { MessagingWebhookExceptionCode } from 'src/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 {
+ constructor(
+ message: string,
+ code: MessagingWebhookExceptionCode,
+ { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
+ ) {
+ super(message, code, {
+ userFriendlyMessage:
+ userFriendlyMessage ??
+ getMessagingWebhookExceptionUserFriendlyMessage(code),
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts
index 6faaceb0a4..e443d87eff 100644
--- a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts
@@ -1,67 +1,61 @@
import {
- BadRequestException,
Controller,
HttpCode,
Post,
type RawBodyRequest,
Req,
+ UseFilters,
UseGuards,
} from '@nestjs/common';
import { type Request } from 'express';
-import type SnsPayloadValidator from 'sns-payload-validator';
-import { MessagingWebhookDispatcherService } from 'src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service';
-import { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service';
+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';
-
-type SnsPayload = SnsPayloadValidator.SnsPayload;
+import { isDefined } from 'twenty-shared/utils';
@Controller()
+@UseFilters(MessagingWebhookApiExceptionFilter)
export class MessagingWebhooksController {
constructor(
- private readonly snsSignatureVerifierService: SnsSignatureVerifierService,
- private readonly messagingWebhookDispatcherService: MessagingWebhookDispatcherService,
+ private readonly sesInboundWebhookRouterService: SesInboundWebhookRouterService,
+ private readonly sesOutboundWebhookRouterService: SesOutboundWebhookRouterService,
) {}
- @Post(['webhooks/messaging/ses'])
+ @Post(['webhooks/messaging/ses/inbound'])
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@HttpCode(200)
- async handleSesWebhook(
+ async handleSesInboundWebhook(
@Req() request: RawBodyRequest,
): Promise {
- if (!request.rawBody) {
- throw new BadRequestException('Missing SNS payload');
- }
-
- const payload = this.parseSnsPayload(request.rawBody);
-
- await this.snsSignatureVerifierService.assertAllowedAndSigned(payload);
-
- if (
- payload.Type === 'SubscriptionConfirmation' ||
- payload.Type === 'UnsubscribeConfirmation'
- ) {
- await this.messagingWebhookDispatcherService.confirmSnsSubscription(
- payload.SubscribeURL,
- );
-
- return;
- }
-
- if (payload.Type === 'Notification') {
- await this.messagingWebhookDispatcherService.dispatchSnsNotification(
- payload,
+ if (!isDefined(request.rawBody)) {
+ throw new MessagingWebhookException(
+ 'Missing SNS payload',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY,
);
}
+
+ await this.sesInboundWebhookRouterService.route(request.rawBody);
}
- private parseSnsPayload(rawBody: Buffer): SnsPayload {
- try {
- return JSON.parse(rawBody.toString('utf8')) as SnsPayload;
- } catch {
- throw new BadRequestException('Invalid SNS payload');
+ @Post(['webhooks/messaging/ses/outbound'])
+ @UseGuards(PublicEndpointGuard, NoPermissionGuard)
+ @HttpCode(200)
+ async handleSesOutboundWebhook(
+ @Req() request: RawBodyRequest,
+ ): Promise {
+ if (!isDefined(request.rawBody)) {
+ throw new MessagingWebhookException(
+ 'Missing SNS payload',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_MISSING_REQUEST_BODY,
+ );
}
+
+ await this.sesOutboundWebhookRouterService.route(request.rawBody);
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts
index 713ad18c3c..d53fc69280 100644
--- a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts
@@ -1,13 +1,25 @@
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 { MessagingWebhookDispatcherService } from 'src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service';
+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],
+ imports: [TwentyConfigModule, EmailingDomainModule],
controllers: [MessagingWebhooksController],
- providers: [SnsSignatureVerifierService, MessagingWebhookDispatcherService],
+ providers: [
+ SnsSignatureVerifierService,
+ SnsSubscriptionConfirmerService,
+ SesInboundMailHandlerService,
+ SesOutboundSendingStateHandlerService,
+ SesInboundWebhookRouterService,
+ SesOutboundWebhookRouterService,
+ ],
})
export class MessagingWebhooksModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/__tests__/ses-outbound-sending-state-handler.service.spec.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/__tests__/ses-outbound-sending-state-handler.service.spec.ts
new file mode 100644
index 0000000000..bb532e8d15
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/__tests__/ses-outbound-sending-state-handler.service.spec.ts
@@ -0,0 +1,84 @@
+import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
+import { type EmailingDomainTenantStatusService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain-tenant-status.service';
+import { SesOutboundSendingStateHandlerService } from 'src/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();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service.ts
deleted file mode 100644
index 5211f8be54..0000000000
--- a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Injectable, Logger } from '@nestjs/common';
-
-import type SnsPayloadValidator from 'sns-payload-validator';
-
-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';
-
-type SnsPayload = SnsPayloadValidator.SnsPayload;
-
-@Injectable()
-export class MessagingWebhookDispatcherService {
- private readonly logger = new Logger(MessagingWebhookDispatcherService.name);
-
- constructor(
- @InjectMessageQueue(MessageQueue.messagingQueue)
- private readonly messageQueueService: MessageQueueService,
- ) {}
-
- private static readonly SNS_SUBSCRIBE_URL_PATTERN =
- /^https:\/\/sns\.[a-z0-9-]+\.amazonaws\.com\//;
-
- async confirmSnsSubscription(
- subscribeUrl: string | undefined,
- ): Promise {
- if (!subscribeUrl) {
- return;
- }
-
- if (
- !MessagingWebhookDispatcherService.SNS_SUBSCRIBE_URL_PATTERN.test(
- subscribeUrl,
- )
- ) {
- this.logger.error(
- `Refusing to fetch non-AWS SubscribeURL: ${subscribeUrl}`,
- );
-
- return;
- }
-
- const response = await fetch(subscribeUrl);
-
- if (!response.ok) {
- this.logger.error(
- `Failed to confirm SNS subscription via ${subscribeUrl}: ${response.status}`,
- );
-
- return;
- }
-
- this.logger.log(`Confirmed SNS subscription via ${subscribeUrl}`);
- }
-
- async dispatchSnsNotification(payload: SnsPayload): Promise {
- const notification = this.parseSesInboundNotification(payload.Message);
-
- if (!notification) {
- this.logger.warn(
- `SNS message ${payload.MessageId} has invalid JSON body`,
- );
-
- return;
- }
-
- const { receipt } = notification;
-
- if (receipt.action.type !== 'S3') {
- this.logger.warn(
- `SNS message ${payload.MessageId} has unsupported action type ${receipt.action.type}`,
- );
-
- return;
- }
-
- await this.messageQueueService.add(
- MessagingInboundEmailImportJob.name,
- {
- s3Key: receipt.action.objectKey,
- envelopeRecipients: receipt.recipients,
- },
- );
- }
-
- private parseSesInboundNotification(
- rawJson: string,
- ): SesInboundNotification | null {
- try {
- return JSON.parse(rawJson) as SesInboundNotification;
- } catch {
- return null;
- }
- }
-}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service.ts
new file mode 100644
index 0000000000..705dcd0252
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-mail-handler.service.ts
@@ -0,0 +1,43 @@
+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 {
+ 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(
+ MessagingInboundEmailImportJob.name,
+ {
+ s3Key: receipt.action.objectKey,
+ envelopeRecipients: receipt.recipients,
+ },
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service.ts
new file mode 100644
index 0000000000..0f79cf4dc8
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-inbound-webhook-router.service.ts
@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+
+import type SnsPayloadValidator from 'sns-payload-validator';
+import { isDefined, parseJson } from 'twenty-shared/utils';
+
+import { MessagingWebhookExceptionCode } from 'src/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 {
+ const payload = parseJson(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(payload.Message);
+
+ if (!isDefined(notification)) {
+ throw new MessagingWebhookException(
+ 'Invalid SNS notification message',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
+ );
+ }
+
+ await this.sesInboundMailHandlerService.handle(
+ notification,
+ payload.MessageId,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service.ts
new file mode 100644
index 0000000000..6cac20324e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-sending-state-handler.service.ts
@@ -0,0 +1,58 @@
+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 {
+ 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;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service.ts
new file mode 100644
index 0000000000..3f174f941e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/ses-outbound-webhook-router.service.ts
@@ -0,0 +1,59 @@
+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 {
+ const payload = parseJson(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(payload.Message);
+
+ if (!isDefined(event)) {
+ throw new MessagingWebhookException(
+ 'Invalid SNS notification message',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_PAYLOAD,
+ );
+ }
+
+ await this.sesOutboundSendingStateHandlerService.handle(event);
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts
index c46b8a4b52..d906c72e78 100644
--- a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts
@@ -1,7 +1,9 @@
-import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
+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;
@@ -17,7 +19,10 @@ export class SnsSignatureVerifierService {
if (!this.isTopicAllowlisted(payload.TopicArn)) {
this.logger.warn(`SNS topic ${payload.TopicArn} is not in allowlist`);
- throw new ForbiddenException('SNS topic not allowed');
+ throw new MessagingWebhookException(
+ 'SNS topic not allowed',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_FORBIDDEN_TOPIC,
+ );
}
try {
@@ -28,7 +33,10 @@ export class SnsSignatureVerifierService {
this.logger.warn(`SNS signature verification failed: ${errorMessage}`);
- throw new ForbiddenException('SNS signature invalid');
+ throw new MessagingWebhookException(
+ 'SNS signature invalid',
+ MessagingWebhookExceptionCode.MESSAGING_WEBHOOK_INVALID_SIGNATURE,
+ );
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service.ts
new file mode 100644
index 0000000000..4fe16743b7
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-subscription-confirmer.service.ts
@@ -0,0 +1,39 @@
+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 {
+ 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}`);
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type.ts
new file mode 100644
index 0000000000..d2b7dbb553
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/ses-event-bridge-notification.type.ts
@@ -0,0 +1,15 @@
+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;
+ };
+ };
+ };
+};
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/__tests__/parse-workspace-id-from-aws-ses-resource-arn.util.spec.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/__tests__/parse-workspace-id-from-aws-ses-resource-arn.util.spec.ts
new file mode 100644
index 0000000000..fc3deed65f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/__tests__/parse-workspace-id-from-aws-ses-resource-arn.util.spec.ts
@@ -0,0 +1,53 @@
+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();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/get-messaging-webhook-exception-status-code.util.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/get-messaging-webhook-exception-status-code.util.ts
new file mode 100644
index 0000000000..f8cd019a66
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/get-messaging-webhook-exception-status-code.util.ts
@@ -0,0 +1,24 @@
+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);
+ }
+ }
+};
diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util.ts
new file mode 100644
index 0000000000..2cbd7cf6b8
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/utils/parse-workspace-id-from-aws-ses-resource-arn.util.ts
@@ -0,0 +1,29 @@
+import { AWS_SES_RESOURCE_NAME_PREFIX } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/constants/aws-ses-resource-name-prefix.constant';
+import { isDefined } from 'twenty-shared/utils';
+
+export const parseWorkspaceIdFromAwsSesResourceArn = (
+ resourceArn: string,
+): string | null => {
+ const slashIndex = resourceArn.indexOf('/');
+
+ if (slashIndex === -1) {
+ return null;
+ }
+
+ const afterPrefix = resourceArn.slice(slashIndex + 1);
+ const resourceName = afterPrefix.split('/')[0];
+
+ if (!isDefined(resourceName)) {
+ return null;
+ }
+
+ const expectedPrefix = `${AWS_SES_RESOURCE_NAME_PREFIX}-`;
+
+ if (!resourceName.startsWith(expectedPrefix)) {
+ return null;
+ }
+
+ const workspaceId = resourceName.slice(expectedPrefix.length);
+
+ return workspaceId.length > 0 ? workspaceId : null;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
index 0e9eb8d408..8fe590325f 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
@@ -22,6 +22,10 @@ import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custo
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
+import {
+ EmailingDomainWorkspaceCleanupJob,
+ type EmailingDomainWorkspaceCleanupJobData,
+} from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import {
FileWorkspaceFolderDeletionJob,
@@ -508,6 +512,11 @@ export class WorkspaceService extends TypeOrmQueryService {
{ workspaceId: id },
);
+ await this.messageQueueService.add(
+ EmailingDomainWorkspaceCleanupJob.name,
+ { workspaceId: id },
+ );
+
if (workspace.customDomain) {
await this.dnsManagerService.deleteHostnameSilently(
workspace.customDomain,
diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts
index f3a229827f..1f19c81466 100644
--- a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts
@@ -82,7 +82,10 @@ export class ConnectedAccountMetadataService {
);
}
- if (connectedAccount.userWorkspaceId !== userWorkspaceId) {
+ if (
+ connectedAccount.visibility !== 'workspace' &&
+ connectedAccount.userWorkspaceId !== userWorkspaceId
+ ) {
throw new ConnectedAccountException(
`Connected account ${id} does not belong to user workspace ${userWorkspaceId}`,
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION,
@@ -107,6 +110,19 @@ export class ConnectedAccountMetadataService {
return accounts.map((account) => account.id);
}
+ async getWorkspaceSharedConnectedAccountIds({
+ workspaceId,
+ }: {
+ workspaceId: string;
+ }): Promise {
+ const accounts = await this.repository.find({
+ where: { workspaceId, visibility: 'workspace' },
+ select: ['id'],
+ });
+
+ return accounts.map((account) => account.id);
+ }
+
async create(
data: Partial & {
workspaceId: string;
diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel-metadata.service.ts
index 39499732c9..b0f976793f 100644
--- a/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel-metadata.service.ts
@@ -55,8 +55,15 @@ export class MessageChannelMetadataService {
workspaceId,
});
+ const sharedAccountIds =
+ await this.connectedAccountMetadataService.getWorkspaceSharedConnectedAccountIds(
+ { workspaceId },
+ );
+
return this.findByConnectedAccountIds({
- connectedAccountIds: userAccountIds,
+ connectedAccountIds: [
+ ...new Set([...userAccountIds, ...sharedAccountIds]),
+ ],
workspaceId,
});
}
@@ -137,6 +144,16 @@ export class MessageChannelMetadataService {
);
}
+ const connectedAccount =
+ await this.connectedAccountMetadataService.findById({
+ id: messageChannel.connectedAccountId,
+ workspaceId,
+ });
+
+ if (connectedAccount?.visibility === 'workspace') {
+ return messageChannel;
+ }
+
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
@@ -204,7 +221,7 @@ export class MessageChannelMetadataService {
storageType !== StorageDriverType.S_3
) {
throw new MessageChannelException(
- 'Email group is not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
+ 'Email handles are not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED,
);
}
@@ -222,6 +239,7 @@ export class MessageChannelMetadataService {
userWorkspaceId,
accessToken: null,
refreshToken: null,
+ visibility: 'workspace',
});
const messageChannel = await this.create({
diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
index 4a56825ceb..848dfc3ce8 100644
--- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
@@ -234,7 +234,6 @@ describe('WorkspaceEntityManager', () => {
IS_JSON_FILTER_ENABLED: false,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
- IS_EMAILING_DOMAIN_ENABLED: false,
IS_EMAIL_GROUP_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
@@ -258,7 +257,6 @@ describe('WorkspaceEntityManager', () => {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
- IS_EMAILING_DOMAIN_ENABLED: false,
},
permissionsPerRoleId: {},
eventEmitterService: mockInternalContext.eventEmitterService,
diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
index 61aab31f57..eed5ea196c 100644
--- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts
@@ -30,11 +30,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
- {
- key: FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
- workspaceId: workspaceId,
- value: true,
- },
{
key: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
workspaceId: workspaceId,
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service.ts
new file mode 100644
index 0000000000..164e18bcfa
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service.ts
@@ -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,
+ private readonly emailingDomainService: EmailingDomainService,
+ ) {}
+
+ async sendMessage(
+ sendMessageInput: SendMessageInput,
+ connectedAccount: ConnectedAccountEntity,
+ ): Promise {
+ 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 {
+ throw new MessageChannelException(
+ 'Email handle channels do not support drafts.',
+ MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
+ );
+ }
+
+ private async resolveEmailingDomain(
+ connectedAccount: ConnectedAccountEntity,
+ ): Promise {
+ 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];
+ }
+}
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts
index 3b5a199fc3..3d670766e8 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/messaging-send-manager.module.ts
@@ -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,
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
index a25b854f73..e32629b63f 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
@@ -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:
diff --git a/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts b/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts
index bd42fafc81..c08529c6e0 100644
--- a/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts
+++ b/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts
@@ -178,7 +178,7 @@ describe('MCP Controller (integration)', () => {
expect(hasResultContent || hasError).toBe(true);
}
- });
+ }, 60_000);
it('should list prompts and resources as empty arrays', async () => {
const prompts = await postMcp({
diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts
index 064b016666..b73e6182cc 100644
--- a/packages/twenty-shared/src/types/FeatureFlagKey.ts
+++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts
@@ -3,7 +3,6 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
- IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts
index 08c4f0862d..c124a9f622 100644
--- a/packages/twenty-shared/src/types/SettingsPath.ts
+++ b/packages/twenty-shared/src/types/SettingsPath.ts
@@ -30,8 +30,8 @@ export enum SettingsPath {
NewEmailGroupChannel = 'general/new-email-group',
PublicDomain = 'applications/public-domain',
NewApprovedAccessDomain = 'members/approved-access-domain/new',
- NewEmailingDomain = 'applications/emailing-domain/new',
- EmailingDomainDetail = 'applications/emailing-domain/:domainId',
+ NewEmailingDomain = 'general/emailing-domain/new',
+ EmailingDomainDetail = 'general/emailing-domain/:domainId',
Updates = 'updates',
AI = 'ai',
AiUsageUserDetail = 'ai/usage/user/:userWorkspaceId',