From 4da88786974d5b50e60cba01196ebfe5876d3ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 9 May 2026 11:00:57 +0200 Subject: [PATCH] feat: add email forwarding message channel (#19535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add email forwarding as a new message channel type, allowing users to forward emails from addresses like `support@mycompany.com` into Twenty - Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron job, parsed, routed to the correct workspace/channel, and persisted as messages - Dedicated settings page at `/settings/accounts/new-email-forwarding` where users provide their source email handle and receive a unique forwarding address - Forwarding channels bypass the IMAP/mailbox sync state machine — they skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages - Forwarding address section shown at the top of the Emails settings page so users can find/copy their addresses after initial setup - Tab names for forwarding channels display the user-provided handle (e.g. `support@mycompany.com`) instead of the internal routing address - Shared utilities extracted from IMAP driver: `extractThreadId`, `extractParticipants`, `extractAddresses` to avoid code duplication - Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/` prefix — no separate bucket needed - Feature gated behind `isEmailForwardingEnabled` client config (requires `INBOUND_EMAIL_DOMAIN` + S3 storage) ## New backend modules - `InboundEmailS3ClientProvider` — lazy-initialized S3 client using existing storage config - `InboundEmailStorageService` — S3 operations (get, move to processed/unmatched/failed) - `InboundEmailParserService` — RFC 822 parsing via `postal-mime`, builds `MessageWithParticipants` - `InboundEmailImportService` — orchestrates download → parse → route → persist → archive - `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix, enqueues import jobs - `CreateEmailForwardingChannelInput` DTO — accepts user-provided `handle` ## New frontend components - `SettingsAccountsNewEmailForwardingChannel` — dedicated page with handle input form + forwarding address result - `SettingsAccountsEmailForwardingSection` — forwarding address list on the Emails settings page - `useConnectedAccountHandleMap` — shared hook for account ID → handle lookup - `useCreateEmailForwardingChannel` — mutation hook accepting handle parameter ## Test plan - [x] 17 unit tests for inbound email import service (all outcomes: imported, unmatched, loop_dropped, unconfigured, parse_failed, persist_failed) - [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases - [x] 11 tests for `extractEnvelopeRecipient` utility - [x] TypeScript typechecks pass for both twenty-server and twenty-front - [x] Lint passes for both packages - [ ] Manual: create forwarding channel, verify forwarding address generated - [ ] Manual: send email to forwarding address, verify it appears in Twenty https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm --------- Co-authored-by: Claude Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 --- .../src/metadata/generated/schema.graphql | 15 + .../src/metadata/generated/schema.ts | 39 +- .../src/metadata/generated/types.ts | 425 ++++++++++-------- .../src/generated-admin/graphql.ts | 1 + .../src/generated-metadata/graphql.ts | 46 +- .../modules/accounts/types/MessageChannel.ts | 7 +- .../utils/hasMissingDraftEmailScopes.ts | 1 + .../modules/app/components/SettingsRoutes.tsx | 24 + .../client-config/hooks/useClientConfig.ts | 14 +- .../states/isEmailGroupEnabledState.ts | 6 + .../client-config/types/ClientConfig.ts | 1 + .../SettingsAccountsListEmptyStateCard.tsx | 7 +- .../SettingsAccountsMessageChannelDetails.tsx | 66 +-- ...ttingsAccountsMessageChannelsContainer.tsx | 8 +- .../SettingsAccountsNewEmailGroupChannel.tsx | 89 ++++ ...sAccountsMessageChannelDetails.stories.tsx | 2 + .../mutations/createEmailGroupChannel.ts | 18 + .../mutations/deleteEmailGroupChannel.ts | 9 + .../graphql/queries/getMyMessageChannels.ts | 4 + .../hooks/useCreateEmailGroupChannel.ts | 49 ++ .../hooks/useDeleteEmailGroupChannel.ts | 31 ++ .../utils/__tests__/computeSyncStatus.test.ts | 14 + .../accounts/utils/computeSyncStatus.ts | 2 +- .../SettingsWorkspaceEmailGroupSection.tsx | 136 ++++++ .../field/display/components/ActorDisplay.tsx | 1 + .../src/pages/settings/SettingsWorkspace.tsx | 16 +- ...ttingsWorkspaceEmailGroupChannelDetail.tsx | 151 +++++++ .../src/testing/mock-data/config.ts | 1 + packages/twenty-server/package.json | 1 + ...8256809018-add-email-group-channel-type.ts | 35 ++ .../instance-commands.constant.ts | 2 + .../auth/services/auth.service.ts | 1 + .../client-config.controller.spec.ts | 1 + .../client-config/client-config.entity.ts | 3 + .../services/client-config.service.spec.ts | 1 + .../services/client-config.service.ts | 6 + .../engine/core-modules/core-engine.module.ts | 2 + .../messaging-webhooks.controller.ts | 67 +++ .../messaging-webhooks.module.ts | 13 + .../messaging-webhook-dispatcher.service.ts | 99 ++++ .../sns-signature-verifier.service.ts | 50 +++ .../types/sns-message.type.ts | 5 + .../twenty-config/config-variables.ts | 18 + ...ompare-and-stringify.constant.spec.ts.snap | 2 +- .../dtos/create-email-group-channel.input.ts | 12 + .../dtos/create-email-group-channel.output.ts | 12 + .../message-channel-metadata.service.ts | 72 +++ .../message-channel.exception.ts | 3 + .../resolvers/message-channel.resolver.ts | 79 +++- ...nnel-graphql-api-exception-handler.util.ts | 3 + .../workspace-entity-manager.spec.ts | 1 + .../core/utils/seed-feature-flags.util.ts | 5 + ...annel-standard-flat-field-metadata.util.ts | 7 + packages/twenty-server/src/main.ts | 1 + .../services/channel-sync.service.ts | 4 +- .../services/email-alias-manager.service.ts | 1 + ...onnected-account-refresh-tokens.service.ts | 2 + .../messaging-message-list-fetch.cron.job.ts | 8 +- .../messaging-messages-import.cron.job.ts | 8 +- ...launch-failed-message-channels.cron.job.ts | 4 +- .../services/imap-get-messages.service.ts | 78 +--- ...nbound-email-local-part-prefix.constant.ts | 1 + ...-email-local-part-random-bytes.constant.ts | 1 + .../messaging-inbound-email-driver.module.ts | 29 ++ .../inbound-email-s3-client.provider.ts | 80 ++++ .../services/inbound-email-import.service.ts | 127 ++++++ .../services/inbound-email-parser.service.ts | 40 ++ .../services/inbound-email-storage.service.ts | 43 ++ .../inbound-email-import-outcome.type.ts | 4 + .../types/parsed-inbound-message.type.ts | 8 + .../messaging-inbound-email-import.job.ts | 37 ++ .../messaging-import-manager.module.ts | 13 + ...xtract-addresses-from-parsed-email.util.ts | 25 ++ ...act-participants-from-parsed-email.util.ts | 21 + ...xtract-thread-id-from-parsed-email.util.ts | 35 ++ .../messaging-message-outbound.service.ts | 8 + .../src/types/ConnectedAccountProvider.ts | 1 + .../twenty-shared/src/types/FeatureFlagKey.ts | 1 + .../src/types/MessageChannelType.ts | 1 + .../twenty-shared/src/types/SettingsPath.ts | 2 + yarn.lock | 10 + 81 files changed, 1967 insertions(+), 309 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/states/isEmailGroupEnabledState.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx create mode 100644 packages/twenty-front/src/modules/settings/accounts/graphql/mutations/createEmailGroupChannel.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/graphql/mutations/deleteEmailGroupChannel.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/hooks/useCreateEmailGroupChannel.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/hooks/useDeleteEmailGroupChannel.ts create mode 100644 packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx create mode 100644 packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts create 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/sns-signature-verifier.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/sns-message.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 1a2d277b4a..0f2ab51ce1 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1752,6 +1752,7 @@ enum FeatureFlagKey { IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED IS_PUBLIC_DOMAIN_ENABLED IS_EMAILING_DOMAIN_ENABLED + IS_EMAIL_GROUP_ENABLED IS_JUNCTION_RELATIONS_ENABLED IS_CONNECTED_ACCOUNT_MIGRATED IS_RICH_TEXT_V1_MIGRATED @@ -1926,6 +1927,7 @@ type ClientConfig { isGoogleCalendarEnabled: Boolean! isConfigVariablesInDbEnabled: Boolean! isImapSmtpCaldavEnabled: Boolean! + isEmailGroupEnabled: Boolean! allowRequestsToTwentyIcons: Boolean! calendarBookingPageId: String isCloudflareIntegrationEnabled: Boolean! @@ -2777,6 +2779,7 @@ type MessageChannel { connectedAccountId: UUID! createdAt: DateTime! updatedAt: DateTime! + connectedAccount: ConnectedAccountPublicDTO } enum MessageChannelVisibility { @@ -2788,6 +2791,7 @@ enum MessageChannelVisibility { enum MessageChannelType { EMAIL SMS + EMAIL_GROUP } enum MessageChannelContactAutoCreationPolicy { @@ -2826,6 +2830,11 @@ enum MessageChannelSyncStage { FAILED } +type CreateEmailGroupChannelOutput { + messageChannel: MessageChannel! + forwardingAddress: String! +} + type MessageFolder { id: UUID! name: String @@ -3243,6 +3252,8 @@ type Mutation { updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder! updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]! updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel! + createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput! + deleteEmailGroupChannel(id: UUID!): MessageChannel! deleteConnectedAccount(id: UUID!): ConnectedAccountDTO! updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel! createWebhook(input: CreateWebhookInput!): Webhook! @@ -4171,6 +4182,10 @@ input UpdateMessageChannelInputUpdates { excludeGroupEmails: Boolean } +input CreateEmailGroupChannelInput { + handle: String! +} + input UpdateCalendarChannelInput { id: UUID! update: UpdateCalendarChannelInputUpdates! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 6a72a63d32..4919e7ef7f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1388,7 +1388,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_DATASOURCE_MIGRATED' | 'IS_BILLING_V2_ENABLED' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_DATASOURCE_MIGRATED' | 'IS_BILLING_V2_ENABLED' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -1554,6 +1554,7 @@ export interface ClientConfig { isGoogleCalendarEnabled: Scalars['Boolean'] isConfigVariablesInDbEnabled: Scalars['Boolean'] isImapSmtpCaldavEnabled: Scalars['Boolean'] + isEmailGroupEnabled: Scalars['Boolean'] allowRequestsToTwentyIcons: Scalars['Boolean'] calendarBookingPageId?: Scalars['String'] isCloudflareIntegrationEnabled: Scalars['Boolean'] @@ -2459,12 +2460,13 @@ export interface MessageChannel { connectedAccountId: Scalars['UUID'] createdAt: Scalars['DateTime'] updatedAt: Scalars['DateTime'] + connectedAccount?: ConnectedAccountPublicDTO __typename: 'MessageChannel' } export type MessageChannelVisibility = 'METADATA' | 'SUBJECT' | 'SHARE_EVERYTHING' -export type MessageChannelType = 'EMAIL' | 'SMS' +export type MessageChannelType = 'EMAIL' | 'SMS' | 'EMAIL_GROUP' export type MessageChannelContactAutoCreationPolicy = 'SENT_AND_RECEIVED' | 'SENT' | 'NONE' @@ -2476,6 +2478,12 @@ export type MessageChannelSyncStatus = 'NOT_SYNCED' | 'ONGOING' | 'ACTIVE' | 'FA export type MessageChannelSyncStage = 'PENDING_CONFIGURATION' | 'MESSAGE_LIST_FETCH_PENDING' | 'MESSAGE_LIST_FETCH_SCHEDULED' | 'MESSAGE_LIST_FETCH_ONGOING' | 'MESSAGES_IMPORT_PENDING' | 'MESSAGES_IMPORT_SCHEDULED' | 'MESSAGES_IMPORT_ONGOING' | 'FAILED' +export interface CreateEmailGroupChannelOutput { + messageChannel: MessageChannel + forwardingAddress: Scalars['String'] + __typename: 'CreateEmailGroupChannelOutput' +} + export interface MessageFolder { id: Scalars['UUID'] name?: Scalars['String'] @@ -2772,6 +2780,8 @@ export interface Mutation { updateMessageFolder: MessageFolder updateMessageFolders: MessageFolder[] updateMessageChannel: MessageChannel + createEmailGroupChannel: CreateEmailGroupChannelOutput + deleteEmailGroupChannel: MessageChannel deleteConnectedAccount: ConnectedAccountDTO updateCalendarChannel: CalendarChannel createWebhook: Webhook @@ -4495,6 +4505,7 @@ export interface ClientConfigGenqlSelection{ isGoogleCalendarEnabled?: boolean | number isConfigVariablesInDbEnabled?: boolean | number isImapSmtpCaldavEnabled?: boolean | number + isEmailGroupEnabled?: boolean | number allowRequestsToTwentyIcons?: boolean | number calendarBookingPageId?: boolean | number isCloudflareIntegrationEnabled?: boolean | number @@ -5487,6 +5498,14 @@ export interface MessageChannelGenqlSelection{ connectedAccountId?: boolean | number createdAt?: boolean | number updatedAt?: boolean | number + connectedAccount?: ConnectedAccountPublicDTOGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface CreateEmailGroupChannelOutputGenqlSelection{ + messageChannel?: MessageChannelGenqlSelection + forwardingAddress?: boolean | number __typename?: boolean | number __scalar?: boolean | number } @@ -5828,6 +5847,8 @@ export interface MutationGenqlSelection{ updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} }) updateMessageFolders?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFoldersInput} }) updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} }) + createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} }) + deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} }) deleteConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} }) updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} }) createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} }) @@ -6206,6 +6227,8 @@ export interface UpdateMessageChannelInput {id: Scalars['UUID'],update: UpdateMe export interface UpdateMessageChannelInputUpdates {visibility?: (MessageChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (MessageChannelContactAutoCreationPolicy | null),messageFolderImportPolicy?: (MessageFolderImportPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null),excludeNonProfessionalEmails?: (Scalars['Boolean'] | null),excludeGroupEmails?: (Scalars['Boolean'] | null)} +export interface CreateEmailGroupChannelInput {handle: Scalars['String']} + export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateCalendarChannelInputUpdates} export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)} @@ -8163,6 +8186,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const CreateEmailGroupChannelOutput_possibleTypes: string[] = ['CreateEmailGroupChannelOutput'] + export const isCreateEmailGroupChannelOutput = (obj?: { __typename?: any } | null): obj is CreateEmailGroupChannelOutput => { + if (!obj?.__typename) throw new Error('__typename is missing in "isCreateEmailGroupChannelOutput"') + return CreateEmailGroupChannelOutput_possibleTypes.includes(obj.__typename) + } + + + const MessageFolder_possibleTypes: string[] = ['MessageFolder'] export const isMessageFolder = (obj?: { __typename?: any } | null): obj is MessageFolder => { if (!obj?.__typename) throw new Error('__typename is missing in "isMessageFolder"') @@ -8691,6 +8722,7 @@ export const enumFeatureFlagKey = { IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' 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_CONNECTED_ACCOUNT_MIGRATED: 'IS_CONNECTED_ACCOUNT_MIGRATED' as const, IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const, @@ -8790,7 +8822,8 @@ export const enumMessageChannelVisibility = { export const enumMessageChannelType = { EMAIL: 'EMAIL' as const, - SMS: 'SMS' as const + SMS: 'SMS' as const, + EMAIL_GROUP: 'EMAIL_GROUP' as const } export const enumMessageChannelContactAutoCreationPolicy = { diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 1251f03c64..d715db5293 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -72,14 +72,14 @@ export default { 311, 312, 313, - 315, - 317, - 327, - 334, - 341, - 377, - 452, - 464 + 316, + 318, + 328, + 335, + 342, + 378, + 454, + 466 ], "types": { "BillingProductDTO": { @@ -3754,6 +3754,9 @@ export default { "isImapSmtpCaldavEnabled": [ 6 ], + "isEmailGroupEnabled": [ + 6 + ], "allowRequestsToTwentyIcons": [ 6 ], @@ -5599,6 +5602,9 @@ export default { "updatedAt": [ 4 ], + "connectedAccount": [ + 285 + ], "__typename": [ 1 ] @@ -5610,6 +5616,17 @@ export default { "MessageChannelPendingGroupEmailsAction": {}, "MessageChannelSyncStatus": {}, "MessageChannelSyncStage": {}, + "CreateEmailGroupChannelOutput": { + "messageChannel": [ + 306 + ], + "forwardingAddress": [ + 1 + ], + "__typename": [ + 1 + ] + }, "MessageFolder": { "id": [ 3 @@ -5630,7 +5647,7 @@ export default { 1 ], "pendingSyncAction": [ - 315 + 316 ], "messageChannelId": [ 3 @@ -5648,7 +5665,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 317 + 318 ], "hash": [ 1 @@ -5715,13 +5732,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 318 - ], - "views": [ 319 ], + "views": [ + 320 + ], "collectionHashes": [ - 316 + 317 ], "__typename": [ 1 @@ -5887,7 +5904,7 @@ export default { 2, { "input": [ - 323, + 324, "GetApiKeyInput!" ] } @@ -6000,7 +6017,7 @@ export default { 41, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -6012,7 +6029,7 @@ export default { 15, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -6021,7 +6038,7 @@ export default { 1, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -6104,7 +6121,7 @@ export default { 25, { "input": [ - 325, + 326, "AgentIdInput!" ] } @@ -6164,7 +6181,7 @@ export default { } ], "myMessageFolders": [ - 314, + 315, { "messageChannelId": [ 3 @@ -6203,10 +6220,10 @@ export default { } ], "webhooks": [ - 321 + 322 ], "webhook": [ - 321, + 322, { "id": [ 3, @@ -6215,7 +6232,7 @@ export default { } ], "minimalMetadata": [ - 320 + 321 ], "chatThreads": [ 292 @@ -6411,7 +6428,7 @@ export default { 289, { "input": [ - 326, + 327, "EventLogQueryInput!" ] } @@ -6420,7 +6437,7 @@ export default { 280, { "input": [ - 330, + 331, "PieChartDataInput!" ] } @@ -6429,7 +6446,7 @@ export default { 278, { "input": [ - 331, + 332, "LineChartDataInput!" ] } @@ -6438,7 +6455,7 @@ export default { 275, { "input": [ - 332, + 333, "BarChartDataInput!" ] } @@ -6488,7 +6505,7 @@ export default { 252, { "input": [ - 333 + 334 ] } ], @@ -6543,10 +6560,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 327 + 328 ], "filters": [ - 328 + 329 ], "first": [ 21 @@ -6567,7 +6584,7 @@ export default { 1 ], "dateRange": [ - 329 + 330 ], "recordId": [ 1 @@ -6634,7 +6651,7 @@ export default { 1 ], "operationTypes": [ - 334 + 335 ], "__typename": [ 1 @@ -6646,7 +6663,7 @@ export default { 6, { "input": [ - 336, + 337, "AddQuerySubscriptionInput!" ] } @@ -6655,7 +6672,7 @@ export default { 6, { "input": [ - 337, + 338, "RemoveQueryFromEventStreamInput!" ] } @@ -6664,7 +6681,7 @@ export default { 157, { "inputs": [ - 338, + 339, "[CreateNavigationMenuItemInput!]!" ] } @@ -6673,7 +6690,7 @@ export default { 157, { "input": [ - 338, + 339, "CreateNavigationMenuItemInput!" ] } @@ -6682,7 +6699,7 @@ export default { 157, { "inputs": [ - 339, + 340, "[UpdateOneNavigationMenuItemInput!]!" ] } @@ -6691,7 +6708,7 @@ export default { 157, { "input": [ - 339, + 340, "UpdateOneNavigationMenuItemInput!" ] } @@ -6718,7 +6735,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ] } @@ -6727,7 +6744,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ] } @@ -6736,7 +6753,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ] } @@ -6745,7 +6762,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ] } @@ -6754,7 +6771,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ] } @@ -6763,7 +6780,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ], "fieldMetadataId": [ @@ -6776,7 +6793,7 @@ export default { 129, { "file": [ - 341, + 342, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -6789,7 +6806,7 @@ export default { 61, { "input": [ - 342, + 343, "CreateViewFilterGroupInput!" ] } @@ -6802,7 +6819,7 @@ export default { "String!" ], "input": [ - 343, + 344, "UpdateViewFilterGroupInput!" ] } @@ -6829,7 +6846,7 @@ export default { 63, { "input": [ - 344, + 345, "CreateViewFilterInput!" ] } @@ -6838,7 +6855,7 @@ export default { 63, { "input": [ - 345, + 346, "UpdateViewFilterInput!" ] } @@ -6847,7 +6864,7 @@ export default { 63, { "input": [ - 347, + 348, "DeleteViewFilterInput!" ] } @@ -6856,7 +6873,7 @@ export default { 63, { "input": [ - 348, + 349, "DestroyViewFilterInput!" ] } @@ -6865,7 +6882,7 @@ export default { 69, { "input": [ - 349, + 350, "CreateViewInput!" ] } @@ -6878,7 +6895,7 @@ export default { "String!" ], "input": [ - 350, + 351, "UpdateViewInput!" ] } @@ -6905,7 +6922,7 @@ export default { 69, { "input": [ - 351, + 352, "UpsertViewWidgetInput!" ] } @@ -6914,7 +6931,7 @@ export default { 66, { "input": [ - 356, + 357, "CreateViewSortInput!" ] } @@ -6923,7 +6940,7 @@ export default { 66, { "input": [ - 357, + 358, "UpdateViewSortInput!" ] } @@ -6932,7 +6949,7 @@ export default { 6, { "input": [ - 359, + 360, "DeleteViewSortInput!" ] } @@ -6941,7 +6958,7 @@ export default { 6, { "input": [ - 360, + 361, "DestroyViewSortInput!" ] } @@ -6950,7 +6967,7 @@ export default { 59, { "input": [ - 361, + 362, "UpdateViewFieldInput!" ] } @@ -6959,7 +6976,7 @@ export default { 59, { "input": [ - 363, + 364, "CreateViewFieldInput!" ] } @@ -6968,7 +6985,7 @@ export default { 59, { "inputs": [ - 363, + 364, "[CreateViewFieldInput!]!" ] } @@ -6977,7 +6994,7 @@ export default { 59, { "input": [ - 364, + 365, "DeleteViewFieldInput!" ] } @@ -6986,7 +7003,7 @@ export default { 59, { "input": [ - 365, + 366, "DestroyViewFieldInput!" ] } @@ -6995,7 +7012,7 @@ export default { 68, { "input": [ - 366, + 367, "UpdateViewFieldGroupInput!" ] } @@ -7004,7 +7021,7 @@ export default { 68, { "input": [ - 368, + 369, "CreateViewFieldGroupInput!" ] } @@ -7013,7 +7030,7 @@ export default { 68, { "inputs": [ - 368, + 369, "[CreateViewFieldGroupInput!]!" ] } @@ -7022,7 +7039,7 @@ export default { 68, { "input": [ - 369, + 370, "DeleteViewFieldGroupInput!" ] } @@ -7031,7 +7048,7 @@ export default { 68, { "input": [ - 370, + 371, "DestroyViewFieldGroupInput!" ] } @@ -7040,7 +7057,7 @@ export default { 69, { "input": [ - 371, + 372, "UpsertFieldsWidgetInput!" ] } @@ -7049,7 +7066,7 @@ export default { 2, { "input": [ - 374, + 375, "CreateApiKeyInput!" ] } @@ -7058,7 +7075,7 @@ export default { 2, { "input": [ - 375, + 376, "UpdateApiKeyInput!" ] } @@ -7067,7 +7084,7 @@ export default { 2, { "input": [ - 376, + 377, "RevokeApiKeyInput!" ] } @@ -7109,7 +7126,7 @@ export default { 127, { "type": [ - 377, + 378, "AnalyticsType!" ], "name": [ @@ -7222,7 +7239,7 @@ export default { 128, { "input": [ - 378, + 379, "CreateApprovedAccessDomainInput!" ] } @@ -7231,7 +7248,7 @@ export default { 6, { "input": [ - 379, + 380, "DeleteApprovedAccessDomainInput!" ] } @@ -7240,7 +7257,7 @@ export default { 128, { "input": [ - 380, + 381, "ValidateApprovedAccessDomainInput!" ] } @@ -7249,7 +7266,7 @@ export default { 122, { "input": [ - 381, + 382, "CreatePageLayoutTabInput!" ] } @@ -7262,7 +7279,7 @@ export default { "String!" ], "input": [ - 382, + 383, "UpdatePageLayoutTabInput!" ] } @@ -7280,7 +7297,7 @@ export default { 123, { "input": [ - 383, + 384, "CreatePageLayoutInput!" ] } @@ -7293,7 +7310,7 @@ export default { "String!" ], "input": [ - 384, + 385, "UpdatePageLayoutInput!" ] } @@ -7315,7 +7332,7 @@ export default { "String!" ], "input": [ - 385, + 386, "UpdatePageLayoutWithTabsInput!" ] } @@ -7368,7 +7385,7 @@ export default { 84, { "input": [ - 389, + 390, "CreatePageLayoutWidgetInput!" ] } @@ -7381,7 +7398,7 @@ export default { "String!" ], "input": [ - 390, + 391, "UpdatePageLayoutWidgetInput!" ] } @@ -7399,7 +7416,7 @@ export default { 41, { "input": [ - 324, + 325, "LogicFunctionIdInput!" ] } @@ -7408,7 +7425,7 @@ export default { 41, { "input": [ - 391, + 392, "CreateLogicFunctionFromSourceInput!" ] } @@ -7417,7 +7434,7 @@ export default { 166, { "input": [ - 392, + 393, "ExecuteOneLogicFunctionInput!" ] } @@ -7426,7 +7443,7 @@ export default { 6, { "input": [ - 393, + 394, "UpdateLogicFunctionFromSourceInput!" ] } @@ -7435,7 +7452,7 @@ export default { 35, { "input": [ - 395, + 396, "CreateCommandMenuItemInput!" ] } @@ -7444,7 +7461,7 @@ export default { 35, { "input": [ - 396, + 397, "UpdateCommandMenuItemInput!" ] } @@ -7462,7 +7479,7 @@ export default { 34, { "input": [ - 397, + 398, "CreateFrontComponentInput!" ] } @@ -7471,7 +7488,7 @@ export default { 34, { "input": [ - 398, + 399, "UpdateFrontComponentInput!" ] } @@ -7489,7 +7506,7 @@ export default { 55, { "input": [ - 400, + 401, "CreateOneObjectInput!" ] } @@ -7498,7 +7515,7 @@ export default { 55, { "input": [ - 402, + 403, "DeleteOneObjectInput!" ] } @@ -7507,7 +7524,7 @@ export default { 55, { "input": [ - 403, + 404, "UpdateOneObjectInput!" ] } @@ -7516,7 +7533,7 @@ export default { 25, { "input": [ - 405, + 406, "CreateAgentInput!" ] } @@ -7525,7 +7542,7 @@ export default { 25, { "input": [ - 406, + 407, "UpdateAgentInput!" ] } @@ -7534,7 +7551,7 @@ export default { 25, { "input": [ - 325, + 326, "AgentIdInput!" ] } @@ -7556,7 +7573,7 @@ export default { 29, { "createRoleInput": [ - 407, + 408, "CreateRoleInput!" ] } @@ -7565,7 +7582,7 @@ export default { 29, { "updateRoleInput": [ - 408, + 409, "UpdateRoleInput!" ] } @@ -7583,7 +7600,7 @@ export default { 16, { "upsertObjectPermissionsInput": [ - 410, + 411, "UpsertObjectPermissionsInput!" ] } @@ -7592,7 +7609,7 @@ export default { 27, { "upsertPermissionFlagsInput": [ - 412, + 413, "UpsertPermissionFlagsInput!" ] } @@ -7601,7 +7618,7 @@ export default { 26, { "upsertFieldPermissionsInput": [ - 413, + 414, "UpsertFieldPermissionsInput!" ] } @@ -7610,7 +7627,7 @@ export default { 228, { "input": [ - 415, + 416, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -7641,7 +7658,7 @@ export default { 43, { "input": [ - 418, + 419, "CreateOneFieldMetadataInput!" ] } @@ -7650,7 +7667,7 @@ export default { 43, { "input": [ - 420, + 421, "UpdateOneFieldMetadataInput!" ] } @@ -7659,7 +7676,7 @@ export default { 43, { "input": [ - 422, + 423, "DeleteOneFieldInput!" ] } @@ -7668,7 +7685,7 @@ export default { 65, { "input": [ - 423, + 424, "CreateViewGroupInput!" ] } @@ -7677,7 +7694,7 @@ export default { 65, { "inputs": [ - 423, + 424, "[CreateViewGroupInput!]!" ] } @@ -7686,7 +7703,7 @@ export default { 65, { "input": [ - 424, + 425, "UpdateViewGroupInput!" ] } @@ -7695,7 +7712,7 @@ export default { 65, { "inputs": [ - 424, + 425, "[UpdateViewGroupInput!]!" ] } @@ -7704,7 +7721,7 @@ export default { 65, { "input": [ - 426, + 427, "DeleteViewGroupInput!" ] } @@ -7713,25 +7730,25 @@ export default { 65, { "input": [ - 427, + 428, "DestroyViewGroupInput!" ] } ], "updateMessageFolder": [ - 314, + 315, { "input": [ - 428, + 429, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 314, + 315, { "input": [ - 430, + 431, "UpdateMessageFoldersInput!" ] } @@ -7740,11 +7757,29 @@ export default { 306, { "input": [ - 431, + 432, "UpdateMessageChannelInput!" ] } ], + "createEmailGroupChannel": [ + 314, + { + "input": [ + 434, + "CreateEmailGroupChannelInput!" + ] + } + ], + "deleteEmailGroupChannel": [ + 306, + { + "id": [ + 3, + "UUID!" + ] + } + ], "deleteConnectedAccount": [ 282, { @@ -7758,31 +7793,31 @@ export default { 301, { "input": [ - 433, + 435, "UpdateCalendarChannelInput!" ] } ], "createWebhook": [ - 321, + 322, { "input": [ - 435, + 437, "CreateWebhookInput!" ] } ], "updateWebhook": [ - 321, + 322, { "input": [ - 436, + 438, "UpdateWebhookInput!" ] } ], "deleteWebhook": [ - 321, + 322, { "id": [ 3, @@ -7882,7 +7917,7 @@ export default { 290, { "input": [ - 438, + 440, "CreateSkillInput!" ] } @@ -7891,7 +7926,7 @@ export default { 290, { "input": [ - 439, + 441, "UpdateSkillInput!" ] } @@ -7949,7 +7984,7 @@ export default { 237, { "input": [ - 440, + 442, "GetAuthorizationUrlForSSOInput!" ] } @@ -8203,7 +8238,7 @@ export default { 196, { "input": [ - 441, + 443, "CreateApplicationRegistrationInput!" ] } @@ -8212,7 +8247,7 @@ export default { 7, { "input": [ - 442, + 444, "UpdateApplicationRegistrationInput!" ] } @@ -8239,7 +8274,7 @@ export default { 5, { "input": [ - 444, + 446, "CreateApplicationRegistrationVariableInput!" ] } @@ -8248,7 +8283,7 @@ export default { 5, { "input": [ - 445, + 447, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8266,7 +8301,7 @@ export default { 7, { "file": [ - 341, + 342, "Upload!" ], "universalIdentifier": [ @@ -8337,7 +8372,7 @@ export default { 6, { "input": [ - 447, + 449, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -8371,7 +8406,7 @@ export default { 75, { "data": [ - 448, + 450, "ActivateWorkspaceInput!" ] } @@ -8380,7 +8415,7 @@ export default { 75, { "data": [ - 449, + 451, "UpdateWorkspaceInput!" ] } @@ -8407,7 +8442,7 @@ export default { 6, { "workspaceMigration": [ - 450, + 452, "WorkspaceMigrationInput!" ] } @@ -8425,7 +8460,7 @@ export default { 204, { "input": [ - 453, + 455, "SetupOIDCSsoInput!" ] } @@ -8434,7 +8469,7 @@ export default { 204, { "input": [ - 454, + 456, "SetupSAMLSsoInput!" ] } @@ -8443,7 +8478,7 @@ export default { 200, { "input": [ - 455, + 457, "DeleteSsoInput!" ] } @@ -8452,7 +8487,7 @@ export default { 201, { "input": [ - 456, + 458, "EditSsoInput!" ] } @@ -8483,7 +8518,7 @@ export default { 286, { "input": [ - 457, + 459, "SendEmailInput!" ] } @@ -8509,7 +8544,7 @@ export default { "String!" ], "connectionParameters": [ - 459, + 461, "EmailAccountConnectionParameters!" ], "id": [ @@ -8521,7 +8556,7 @@ export default { 168, { "input": [ - 461, + 463, "UpdateLabPublicFeatureFlagInput!" ] } @@ -8594,7 +8629,7 @@ export default { 77, { "input": [ - 462, + 464, "CreateOneAppTokenInput!" ] } @@ -8649,7 +8684,7 @@ export default { 255, { "file": [ - 341, + 342, "Upload!" ], "applicationUniversalIdentifier": [ @@ -8657,7 +8692,7 @@ export default { "String!" ], "fileFolder": [ - 464, + 466, "FileFolder!" ], "filePath": [ @@ -8766,7 +8801,7 @@ export default { 3 ], "update": [ - 340 + 341 ], "__typename": [ 1 @@ -8873,7 +8908,7 @@ export default { 3 ], "update": [ - 346 + 347 ], "__typename": [ 1 @@ -9029,17 +9064,17 @@ export default { 3 ], "viewFields": [ - 352 - ], - "viewFilters": [ 353 ], - "viewFilterGroups": [ + "viewFilters": [ 354 ], - "viewSorts": [ + "viewFilterGroups": [ 355 ], + "viewSorts": [ + 356 + ], "__typename": [ 1 ] @@ -9143,7 +9178,7 @@ export default { 3 ], "update": [ - 358 + 359 ], "__typename": [ 1 @@ -9178,7 +9213,7 @@ export default { 3 ], "update": [ - 362 + 363 ], "__typename": [ 1 @@ -9254,7 +9289,7 @@ export default { 3 ], "update": [ - 367 + 368 ], "__typename": [ 1 @@ -9318,10 +9353,10 @@ export default { 3 ], "groups": [ - 372 + 373 ], "fields": [ - 373 + 374 ], "__typename": [ 1 @@ -9341,7 +9376,7 @@ export default { 6 ], "fields": [ - 373 + 374 ], "__typename": [ 1 @@ -9510,7 +9545,7 @@ export default { 3 ], "tabs": [ - 386 + 387 ], "__typename": [ 1 @@ -9533,7 +9568,7 @@ export default { 88 ], "widgets": [ - 387 + 388 ], "__typename": [ 1 @@ -9556,7 +9591,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9605,7 +9640,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9631,7 +9666,7 @@ export default { 3 ], "gridPosition": [ - 388 + 389 ], "position": [ 15 @@ -9703,7 +9738,7 @@ export default { 3 ], "update": [ - 394 + 395 ], "__typename": [ 1 @@ -9863,7 +9898,7 @@ export default { 3 ], "update": [ - 399 + 400 ], "__typename": [ 1 @@ -9882,7 +9917,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 401 + 402 ], "__typename": [ 1 @@ -9942,7 +9977,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 404 + 405 ], "id": [ 3 @@ -10114,7 +10149,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 409 + 410 ], "id": [ 3 @@ -10169,7 +10204,7 @@ export default { 3 ], "objectPermissions": [ - 411 + 412 ], "__typename": [ 1 @@ -10211,7 +10246,7 @@ export default { 3 ], "fieldPermissions": [ - 414 + 415 ], "__typename": [ 1 @@ -10242,10 +10277,10 @@ export default { 3 ], "predicates": [ - 416 + 417 ], "predicateGroups": [ - 417 + 418 ], "__typename": [ 1 @@ -10305,7 +10340,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 419 + 420 ], "__typename": [ 1 @@ -10378,7 +10413,7 @@ export default { 3 ], "update": [ - 421 + 422 ], "__typename": [ 1 @@ -10470,7 +10505,7 @@ export default { 3 ], "update": [ - 425 + 426 ], "__typename": [ 1 @@ -10514,7 +10549,7 @@ export default { 3 ], "update": [ - 429 + 430 ], "__typename": [ 1 @@ -10533,7 +10568,7 @@ export default { 3 ], "update": [ - 429 + 430 ], "__typename": [ 1 @@ -10544,7 +10579,7 @@ export default { 3 ], "update": [ - 432 + 433 ], "__typename": [ 1 @@ -10576,12 +10611,20 @@ export default { 1 ] }, + "CreateEmailGroupChannelInput": { + "handle": [ + 1 + ], + "__typename": [ + 1 + ] + }, "UpdateCalendarChannelInput": { "id": [ 3 ], "update": [ - 434 + 436 ], "__typename": [ 1 @@ -10629,7 +10672,7 @@ export default { 3 ], "update": [ - 437 + 439 ], "__typename": [ 1 @@ -10734,7 +10777,7 @@ export default { 1 ], "update": [ - 443 + 445 ], "__typename": [ 1 @@ -10782,7 +10825,7 @@ export default { 1 ], "update": [ - 446 + 448 ], "__typename": [ 1 @@ -10897,7 +10940,7 @@ export default { }, "WorkspaceMigrationInput": { "actions": [ - 451 + 453 ], "__typename": [ 1 @@ -10905,10 +10948,10 @@ export default { }, "WorkspaceMigrationDeleteActionInput": { "type": [ - 452 + 454 ], "metadataName": [ - 317 + 318 ], "universalIdentifier": [ 1 @@ -11000,7 +11043,7 @@ export default { 1 ], "files": [ - 458 + 460 ], "__typename": [ 1 @@ -11019,13 +11062,13 @@ export default { }, "EmailAccountConnectionParameters": { "IMAP": [ - 460 + 462 ], "SMTP": [ - 460 + 462 ], "CALDAV": [ - 460 + 462 ], "__typename": [ 1 @@ -11064,7 +11107,7 @@ export default { }, "CreateOneAppTokenInput": { "appToken": [ - 463 + 465 ], "__typename": [ 1 @@ -11093,7 +11136,7 @@ export default { 229, { "input": [ - 466, + 468, "LogicFunctionLogsInput!" ] } diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index ff6de35d18..bca0c6e419 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -275,6 +275,7 @@ export enum FeatureFlagKey { IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', IS_DATASOURCE_MIGRATED = 'IS_DATASOURCE_MIGRATED', 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', IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 39e8a9291a..39f9d29ca5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -841,6 +841,7 @@ export type ClientConfig = { isClickHouseConfigured: Scalars['Boolean']; isCloudflareIntegrationEnabled: Scalars['Boolean']; isConfigVariablesInDbEnabled: Scalars['Boolean']; + isEmailGroupEnabled: Scalars['Boolean']; isEmailVerificationRequired: Scalars['Boolean']; isGoogleCalendarEnabled: Scalars['Boolean']; isGoogleMessagingEnabled: Scalars['Boolean']; @@ -1035,6 +1036,16 @@ export type CreateCommandMenuItemInput = { workflowVersionId?: InputMaybe; }; +export type CreateEmailGroupChannelInput = { + handle: Scalars['String']; +}; + +export type CreateEmailGroupChannelOutput = { + __typename?: 'CreateEmailGroupChannelOutput'; + forwardingAddress: Scalars['String']; + messageChannel: MessageChannel; +}; + export type CreateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; @@ -1622,6 +1633,7 @@ export enum FeatureFlagKey { IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', IS_DATASOURCE_MIGRATED = 'IS_DATASOURCE_MIGRATED', 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', IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE', @@ -2195,6 +2207,7 @@ export type MarketplaceAppDetail = { export type MessageChannel = { __typename?: 'MessageChannel'; + connectedAccount?: Maybe; connectedAccountId: Scalars['UUID']; contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy; createdAt: Scalars['DateTime']; @@ -2250,6 +2263,7 @@ export enum MessageChannelSyncStatus { export enum MessageChannelType { EMAIL = 'EMAIL', + EMAIL_GROUP = 'EMAIL_GROUP', SMS = 'SMS' } @@ -2359,6 +2373,7 @@ export type Mutation = { createChatThread: AgentChatThread; createCommandMenuItem: CommandMenuItem; createDevelopmentApplication: DevelopmentApplication; + createEmailGroupChannel: CreateEmailGroupChannelOutput; createEmailingDomain: EmailingDomain; createFrontComponent: FrontComponent; createManyNavigationMenuItems: Array; @@ -2396,6 +2411,7 @@ export type Mutation = { deleteCommandMenuItem: CommandMenuItem; deleteConnectedAccount: ConnectedAccountDto; deleteCurrentWorkspace: Workspace; + deleteEmailGroupChannel: MessageChannel; deleteEmailingDomain: Scalars['Boolean']; deleteFrontComponent: FrontComponent; deleteManyNavigationMenuItems: Array; @@ -2634,6 +2650,11 @@ export type MutationCreateDevelopmentApplicationArgs = { }; +export type MutationCreateEmailGroupChannelArgs = { + input: CreateEmailGroupChannelInput; +}; + + export type MutationCreateEmailingDomainArgs = { domain: Scalars['String']; driver: EmailingDomainDriver; @@ -2818,6 +2839,11 @@ export type MutationDeleteConnectedAccountArgs = { }; +export type MutationDeleteEmailGroupChannelArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteEmailingDomainArgs = { id: Scalars['String']; }; @@ -6809,6 +6835,13 @@ export type PieChartDataQueryVariables = Exact<{ export type PieChartDataQuery = { __typename?: 'Query', pieChartData: { __typename?: 'PieChartData', showLegend: boolean, showDataLabels: boolean, showCenterMetric: boolean, hasTooManyGroups: boolean, formattedToRawLookup: any, data: Array<{ __typename?: 'PieChartDataItem', id: string, value: number }> } }; +export type CreateEmailGroupChannelMutationVariables = Exact<{ + input: CreateEmailGroupChannelInput; +}>; + + +export type CreateEmailGroupChannelMutation = { __typename?: 'Mutation', createEmailGroupChannel: { __typename?: 'CreateEmailGroupChannelOutput', forwardingAddress: string, messageChannel: { __typename?: 'MessageChannel', id: string, handle: string, visibility: MessageChannelVisibility, type: MessageChannelType, isSyncEnabled: boolean, excludeGroupEmails: boolean, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy } } }; + export type DeleteConnectedAccountMutationVariables = Exact<{ id: Scalars['UUID']; }>; @@ -6816,6 +6849,13 @@ export type DeleteConnectedAccountMutationVariables = Exact<{ export type DeleteConnectedAccountMutation = { __typename?: 'Mutation', deleteConnectedAccount: { __typename?: 'ConnectedAccountDTO', id: string } }; +export type DeleteEmailGroupChannelMutationVariables = Exact<{ + id: Scalars['UUID']; +}>; + + +export type DeleteEmailGroupChannelMutation = { __typename?: 'Mutation', deleteEmailGroupChannel: { __typename?: 'MessageChannel', id: string } }; + export type SaveImapSmtpCaldavAccountMutationVariables = Exact<{ accountOwnerId: Scalars['UUID']; handle: Scalars['String']; @@ -6894,7 +6934,7 @@ export type MyMessageChannelsQueryVariables = Exact<{ }>; -export type MyMessageChannelsQuery = { __typename?: 'Query', myMessageChannels: Array<{ __typename?: 'MessageChannel', id: string, handle: string, visibility: MessageChannelVisibility, type: MessageChannelType, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, messageFolderImportPolicy: MessageFolderImportPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, isSyncEnabled: boolean, syncStatus: MessageChannelSyncStatus, syncStage: MessageChannelSyncStage, syncStageStartedAt?: string | null, connectedAccountId: string, createdAt: string, updatedAt: string }> }; +export type MyMessageChannelsQuery = { __typename?: 'Query', myMessageChannels: Array<{ __typename?: 'MessageChannel', id: string, handle: string, visibility: MessageChannelVisibility, type: MessageChannelType, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, messageFolderImportPolicy: MessageFolderImportPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, isSyncEnabled: boolean, syncStatus: MessageChannelSyncStatus, syncStage: MessageChannelSyncStage, syncStageStartedAt?: string | null, connectedAccountId: string, createdAt: string, updatedAt: string, connectedAccount?: { __typename?: 'ConnectedAccountPublicDTO', id: string, handle: string } | null }> }; export type MyMessageFoldersQueryVariables = Exact<{ messageChannelId?: InputMaybe; @@ -8014,7 +8054,9 @@ export const FindAllRecordPageLayoutsDocument = {"kind":"Document","definitions" export const BarChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BarChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"barChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"indexBy"}},{"kind":"Field","name":{"kind":"Name","value":"keys"}},{"kind":"Field","name":{"kind":"Name","value":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode; export const LineChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LineChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lineChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode; export const PieChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PieChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pieChartData"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode; +export const CreateEmailGroupChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEmailGroupChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateEmailGroupChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEmailGroupChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageChannel"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}}]}},{"kind":"Field","name":{"kind":"Name","value":"forwardingAddress"}}]}}]}}]} as unknown as DocumentNode; export const DeleteConnectedAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteConnectedAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteConnectedAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const DeleteEmailGroupChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteEmailGroupChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteEmailGroupChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SaveImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SaveImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handle"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EmailAccountConnectionParameters"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"saveImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"accountOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"handle"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handle"}}},{"kind":"Argument","name":{"kind":"Name","value":"connectionParameters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}}},{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}}]}}]}}]} as unknown as DocumentNode; export const StartChannelSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartChannelSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startChannelSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const UpdateCalendarChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCalendarChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCalendarChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCalendarChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}}]}}]}}]} as unknown as DocumentNode; @@ -8025,7 +8067,7 @@ export const ConnectedAccountByIdDocument = {"kind":"Document","definitions":[{" export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; -export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; +export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const RotateApplicationRegistrationClientSecretDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateApplicationRegistrationClientSecret"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateApplicationRegistrationClientSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/accounts/types/MessageChannel.ts b/packages/twenty-front/src/modules/accounts/types/MessageChannel.ts index e0ba8608a9..fa99cacb86 100644 --- a/packages/twenty-front/src/modules/accounts/types/MessageChannel.ts +++ b/packages/twenty-front/src/modules/accounts/types/MessageChannel.ts @@ -1,4 +1,5 @@ import { + type MessageChannelType, type MessageChannelContactAutoCreationPolicy, type MessageChannelSyncStage, type MessageChannelSyncStatus, @@ -10,7 +11,7 @@ export type MessageChannel = { id: string; handle: string; visibility: MessageChannelVisibility; - type: string; + type: MessageChannelType; isContactAutoCreationEnabled: boolean; contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy; messageFolderImportPolicy: MessageFolderImportPolicy; @@ -21,6 +22,10 @@ export type MessageChannel = { syncStage: MessageChannelSyncStage; syncStageStartedAt: string | null; connectedAccountId: string; + connectedAccount: { + id: string; + handle: string; + } | null; createdAt: string; updatedAt: string; __typename: 'MessageChannel'; diff --git a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts index 19741478f1..e22aa29f6e 100644 --- a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts +++ b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts @@ -27,6 +27,7 @@ export const getMissingDraftEmailScopes = ( case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.EMAIL_GROUP: case ConnectedAccountProvider.APP: return []; default: diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 7c80f2bda7..29f43a47be 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -74,6 +74,14 @@ const SettingsEditImapSmtpCaldavConnection = lazy(() => })), ); +const SettingsNewEmailGroupChannel = lazy(() => + import( + '@/settings/accounts/components/SettingsAccountsNewEmailGroupChannel' + ).then((module) => ({ + default: module.SettingsAccountsNewEmailGroupChannel, + })), +); + const SettingsObjectDetailPage = lazy(() => import('~/pages/settings/data-model/SettingsObjectDetailPage').then( (module) => ({ @@ -120,6 +128,14 @@ const SettingsWorkspace = lazy(() => })), ); +const SettingsWorkspaceEmailGroupChannelDetail = lazy(() => + import( + '~/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail' + ).then((module) => ({ + default: module.SettingsWorkspaceEmailGroupChannelDetail, + })), +); + const SettingsDomains = lazy(() => import('~/pages/settings/domains/SettingsDomains').then((module) => ({ default: module.SettingsDomains, @@ -620,6 +636,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( } > } /> + } + /> + } + /> } /> { const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState); - const setIsImapSmtpCaldavEnabled = useSetAtomState( - isImapSmtpCaldavEnabledState, - ); + const setIsEmailGroupEnabled = useSetAtomState(isEmailGroupEnabledState); + const setIsEmailingDomainsEnabled = useSetAtomState( isEmailingDomainsEnabledState, ); + const setIsImapSmtpCaldavEnabled = useSetAtomState( + isImapSmtpCaldavEnabledState, + ); + const setAllowRequestsToTwentyIcons = useSetAtomState( allowRequestsToTwentyIconsState, ); @@ -195,6 +199,7 @@ export const useClientConfig = (): UseClientConfigResult => { setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null); setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled); + setIsEmailGroupEnabled(clientConfig?.isEmailGroupEnabled ?? false); setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled); setAllowRequestsToTwentyIcons(clientConfig?.allowRequestsToTwentyIcons); setIsCloudflareIntegrationEnabled( @@ -233,6 +238,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsDeveloperDefaultSignInPrefilled, setIsEmailVerificationRequired, setIsImapSmtpCaldavEnabled, + setIsEmailGroupEnabled, setIsMultiWorkspaceEnabled, setIsEmailingDomainsEnabled, setIsClickHouseConfigured, diff --git a/packages/twenty-front/src/modules/client-config/states/isEmailGroupEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isEmailGroupEnabledState.ts new file mode 100644 index 0000000000..bd2c215c9b --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isEmailGroupEnabledState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isEmailGroupEnabledState = createAtomState({ + key: 'isEmailGroupEnabled', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index 660e2b7555..5624b80475 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -31,6 +31,7 @@ export type ClientConfig = { isMicrosoftMessagingEnabled: boolean; isMultiWorkspaceEnabled: boolean; isImapSmtpCaldavEnabled: boolean; + isEmailGroupEnabled: boolean; isEmailingDomainsEnabled: boolean; isCloudflareIntegrationEnabled: boolean; isClickHouseConfigured: boolean; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx index f86aaff395..32e494756d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx @@ -5,14 +5,14 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { SettingsCard } from '@/settings/components/SettingsCard'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useContext } from 'react'; import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { IconAt, IconGoogle, IconMicrosoft } from 'twenty-ui/display'; import { UndecoratedLink } from 'twenty-ui/navigation'; -import { useContext } from 'react'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; const StyledCardsContainer = styled.div` @@ -26,6 +26,7 @@ export const SettingsAccountsListEmptyStateCard = () => { const { triggerApisOAuth } = useTriggerApisOAuth(); const { t } = useLingui(); + const isGoogleMessagingEnabled = useAtomStateValue( isGoogleMessagingEnabledState, ); @@ -69,7 +70,7 @@ export const SettingsAccountsListEmptyStateCard = () => { > } - title={t`Connect Account`} + title={t`Connect via IMAP/SMTP`} /> )} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx index 367a16c3ed..91d3d24b74 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx @@ -1,21 +1,23 @@ -import { styled } from '@linaria/react'; - -import { type MessageChannel } from '@/accounts/types/MessageChannel'; -import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel'; import { useMutation } from '@apollo/client/react'; -import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard'; -import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard'; -import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard'; -import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; + +import { + type MessageChannelContactAutoCreationPolicy, + MessageChannelType, + type MessageFolderImportPolicy, +} from 'twenty-shared/types'; import { H2Title, IconBriefcase, IconUsers } from 'twenty-ui/display'; import { Card, Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { type MessageChannel } from '@/accounts/types/MessageChannel'; +import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard'; +import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard'; +import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard'; +import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { type MessageChannelVisibility } from '~/generated/graphql'; -import { - type MessageChannelContactAutoCreationPolicy, - type MessageFolderImportPolicy, -} from 'twenty-shared/types'; type SettingsAccountsMessageChannelDetailsProps = { messageChannel: Pick< @@ -27,9 +29,18 @@ type SettingsAccountsMessageChannelDetailsProps = { | 'excludeGroupEmails' | 'isSyncEnabled' | 'messageFolderImportPolicy' + | 'type' >; }; +type MessageChannelUpdateInput = Partial<{ + visibility: MessageChannelVisibility; + contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy; + excludeGroupEmails: boolean; + excludeNonProfessionalEmails: boolean; + messageFolderImportPolicy: MessageFolderImportPolicy; +}>; + const StyledDetailsContainer = styled.div` display: flex; flex-direction: column; @@ -39,10 +50,10 @@ const StyledDetailsContainer = styled.div` export const SettingsAccountsMessageChannelDetails = ({ messageChannel, }: SettingsAccountsMessageChannelDetailsProps) => { - const [updateMetadataChannel] = useMutation(UPDATE_MESSAGE_CHANNEL); + const [updateMessageChannel] = useMutation(UPDATE_MESSAGE_CHANNEL); - const updateChannel = (update: Record) => { - updateMetadataChannel({ + const updateChannel = (update: MessageChannelUpdateInput) => { + updateMessageChannel({ variables: { input: { id: messageChannel.id, update } }, }); }; @@ -71,18 +82,23 @@ export const SettingsAccountsMessageChannelDetails = ({ updateChannel({ messageFolderImportPolicy: value }); }; + const supportsFolderImportPolicy = + messageChannel.type === MessageChannelType.EMAIL; + return ( -
- - -
+ {supportsFolderImportPolicy && ( +
+ + +
+ )}
{ const messageChannels = allMessageChannels.filter( (channel) => channel.isSyncEnabled && - channel.syncStage !== MessageChannelSyncStage.PENDING_CONFIGURATION, + channel.syncStage !== MessageChannelSyncStage.PENDING_CONFIGURATION && + channel.type !== MessageChannelType.EMAIL_GROUP, ); const tabs = messageChannels.map((messageChannel) => ({ diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx new file mode 100644 index 0000000000..26f98d453a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx @@ -0,0 +1,89 @@ +import { useLingui } from '@lingui/react/macro'; +import { useCallback, useState } from 'react'; +import { z } from 'zod'; + +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; +import { H2Title } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; + +import { useCreateEmailGroupChannel } from '@/settings/accounts/hooks/useCreateEmailGroupChannel'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; + +export const SettingsAccountsNewEmailGroupChannel = () => { + const { t } = useLingui(); + const navigate = useNavigateSettings(); + const { enqueueErrorSnackBar } = useSnackBar(); + const { createEmailGroupChannel, loading } = useCreateEmailGroupChannel(); + + const [handle, setHandle] = useState(''); + + const isHandleValidEmail = z.email().safeParse(handle).success; + const canSave = isHandleValidEmail && !loading; + + const handleSave = useCallback(async () => { + try { + const result = await createEmailGroupChannel(handle); + const messageChannelId = + result.data?.createEmailGroupChannel.messageChannel.id; + + if (messageChannelId) { + navigate(SettingsPath.EmailGroupChannelDetail, { + messageChannelId, + }); + } + } catch { + enqueueErrorSnackBar({ + message: t`Failed to create email group channel. Email group may not be configured on this server.`, + }); + } + }, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]); + + return ( + navigate(SettingsPath.Workspace)} + onSave={handleSave} + /> + } + > + +
+ + +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsMessageChannelDetails.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsMessageChannelDetails.stories.tsx index 21a48eb707..c37feb2c5b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsMessageChannelDetails.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsMessageChannelDetails.stories.tsx @@ -2,6 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; import { MessageChannelContactAutoCreationPolicy, + MessageChannelType, MessageFolderImportPolicy, } from 'twenty-shared/types'; import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails'; @@ -22,6 +23,7 @@ const meta: Meta = { args: { messageChannel: { id: '20202020-ef5a-4822-9e08-ce6e6a4dcb6a', + type: MessageChannelType.EMAIL, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.SENT, excludeNonProfessionalEmails: true, excludeGroupEmails: false, diff --git a/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/createEmailGroupChannel.ts b/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/createEmailGroupChannel.ts new file mode 100644 index 0000000000..b5828133f1 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/createEmailGroupChannel.ts @@ -0,0 +1,18 @@ +import { gql } from '@apollo/client'; + +export const CREATE_EMAIL_GROUP_CHANNEL = gql` + mutation CreateEmailGroupChannel($input: CreateEmailGroupChannelInput!) { + createEmailGroupChannel(input: $input) { + messageChannel { + id + handle + visibility + type + isSyncEnabled + excludeGroupEmails + contactAutoCreationPolicy + } + forwardingAddress + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/deleteEmailGroupChannel.ts b/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/deleteEmailGroupChannel.ts new file mode 100644 index 0000000000..ee2b030454 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/graphql/mutations/deleteEmailGroupChannel.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_EMAIL_GROUP_CHANNEL = gql` + mutation DeleteEmailGroupChannel($id: UUID!) { + deleteEmailGroupChannel(id: $id) { + id + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyMessageChannels.ts b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyMessageChannels.ts index 0bfeedaa2d..bb1a377cb4 100644 --- a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyMessageChannels.ts +++ b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyMessageChannels.ts @@ -17,6 +17,10 @@ export const GET_MY_MESSAGE_CHANNELS = gql` syncStage syncStageStartedAt connectedAccountId + connectedAccount { + id + handle + } createdAt updatedAt } diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useCreateEmailGroupChannel.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useCreateEmailGroupChannel.ts new file mode 100644 index 0000000000..df669f6ccc --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useCreateEmailGroupChannel.ts @@ -0,0 +1,49 @@ +import { useMutation } from '@apollo/client/react'; + +import { + type MessageChannelContactAutoCreationPolicy, + type MessageChannelType, + type MessageChannelVisibility, +} from 'twenty-shared/types'; + +import { CREATE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailGroupChannel'; +import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts'; +import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels'; + +type CreateEmailGroupChannelResult = { + createEmailGroupChannel: { + messageChannel: { + id: string; + handle: string; + visibility: MessageChannelVisibility; + type: MessageChannelType; + isSyncEnabled: boolean; + excludeGroupEmails: boolean; + contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy; + }; + forwardingAddress: string; + }; +}; + +type CreateEmailGroupChannelVariables = { + input: { + handle: string; + }; +}; + +export const useCreateEmailGroupChannel = () => { + const [mutate, { loading, error }] = useMutation< + CreateEmailGroupChannelResult, + CreateEmailGroupChannelVariables + >(CREATE_EMAIL_GROUP_CHANNEL, { + refetchQueries: [ + { query: GET_MY_CONNECTED_ACCOUNTS }, + { query: GET_MY_MESSAGE_CHANNELS }, + ], + }); + + const createEmailGroupChannel = (handle: string) => + mutate({ variables: { input: { handle } } }); + + return { createEmailGroupChannel, loading, error }; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useDeleteEmailGroupChannel.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useDeleteEmailGroupChannel.ts new file mode 100644 index 0000000000..3fc400edb7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useDeleteEmailGroupChannel.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@apollo/client/react'; + +import { DELETE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/deleteEmailGroupChannel'; +import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts'; +import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels'; + +type DeleteEmailGroupChannelResult = { + deleteEmailGroupChannel: { + id: string; + }; +}; + +type DeleteEmailGroupChannelVariables = { + id: string; +}; + +export const useDeleteEmailGroupChannel = () => { + const [mutate, { loading, error }] = useMutation< + DeleteEmailGroupChannelResult, + DeleteEmailGroupChannelVariables + >(DELETE_EMAIL_GROUP_CHANNEL, { + refetchQueries: [ + { query: GET_MY_CONNECTED_ACCOUNTS }, + { query: GET_MY_MESSAGE_CHANNELS }, + ], + }); + + const deleteEmailGroupChannel = (id: string) => mutate({ variables: { id } }); + + return { deleteEmailGroupChannel, loading, error }; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/__tests__/computeSyncStatus.test.ts b/packages/twenty-front/src/modules/settings/accounts/utils/__tests__/computeSyncStatus.test.ts index 4df640ad68..b697a5e4bd 100644 --- a/packages/twenty-front/src/modules/settings/accounts/utils/__tests__/computeSyncStatus.test.ts +++ b/packages/twenty-front/src/modules/settings/accounts/utils/__tests__/computeSyncStatus.test.ts @@ -5,6 +5,7 @@ import { CalendarChannelSyncStatus, MessageChannelSyncStage, MessageChannelSyncStatus, + MessageChannelType, } from 'twenty-shared/types'; describe('computeSyncStatus', () => { @@ -14,6 +15,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.NOT_SYNCED, syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.NOT_SYNCED, @@ -29,6 +31,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.NOT_SYNCED, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.NOT_SYNCED, @@ -44,6 +47,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ACTIVE, syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.ACTIVE, @@ -59,6 +63,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN, syncStage: MessageChannelSyncStage.FAILED, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN, @@ -74,6 +79,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN, syncStage: MessageChannelSyncStage.FAILED, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.ACTIVE, @@ -89,6 +95,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, syncStage: MessageChannelSyncStage.FAILED, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.ACTIVE, @@ -104,6 +111,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ACTIVE, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN, @@ -119,6 +127,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ACTIVE, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, @@ -134,6 +143,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ONGOING, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING, + type: MessageChannelType.EMAIL, }, { @@ -150,6 +160,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ACTIVE, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.ONGOING, @@ -165,6 +176,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.NOT_SYNCED, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.NOT_SYNCED, @@ -180,6 +192,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.ACTIVE, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.NOT_SYNCED, @@ -195,6 +208,7 @@ describe('computeSyncStatus', () => { { syncStatus: MessageChannelSyncStatus.NOT_SYNCED, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: MessageChannelType.EMAIL, }, { syncStatus: CalendarChannelSyncStatus.ACTIVE, diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/computeSyncStatus.ts b/packages/twenty-front/src/modules/settings/accounts/utils/computeSyncStatus.ts index 80cd18af9b..9fb8681a14 100644 --- a/packages/twenty-front/src/modules/settings/accounts/utils/computeSyncStatus.ts +++ b/packages/twenty-front/src/modules/settings/accounts/utils/computeSyncStatus.ts @@ -9,7 +9,7 @@ import { } from 'twenty-shared/types'; export const computeSyncStatus = ( - messageChannel?: Pick, + messageChannel?: Pick, calendarChannel?: Pick, ): SyncStatus => { const { diff --git a/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx new file mode 100644 index 0000000000..baed606b5b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/workspace/components/SettingsWorkspaceEmailGroupSection.tsx @@ -0,0 +1,136 @@ +import { styled } from '@linaria/react'; +import { Trans, useLingui } from '@lingui/react/macro'; + +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 { 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(); + const { channels } = useMyMessageChannels(); + + const emailGroupChannels = channels.filter( + (channel) => channel.type === MessageChannelType.EMAIL_GROUP, + ); + + 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} + + + + + ); + })} + +
+ )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx index c3c9fb1e9b..3d3ca04311 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx @@ -30,6 +30,7 @@ const PROVIDERS_ICON_MAPPING = { [ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail, [ConnectedAccountProvider.OIDC]: IconMail, [ConnectedAccountProvider.SAML]: IconMail, + [ConnectedAccountProvider.EMAIL_GROUP]: IconMail, // App-managed connections aren't email accounts; this case is unreachable // for the EMAIL source but the lookup type still requires every provider. [ConnectedAccountProvider.APP]: IconMail, diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 2c89710f4f..512ae658d0 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,17 +1,29 @@ import { useLingui } from '@lingui/react/macro'; +import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; +import { SettingsWorkspaceEmailGroupSection } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSection'; import { NameField } from '@/settings/workspace/components/NameField'; import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { SettingsPath } from 'twenty-shared/types'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; export const SettingsWorkspace = () => { const { t } = useLingui(); + + const isEmailGroupEnabled = useAtomStateValue(isEmailGroupEnabledState); + const isEmailGroupFeatureEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_EMAIL_GROUP_ENABLED, + ); + const showEmailGroupSection = + isEmailGroupEnabled && isEmailGroupFeatureEnabled; + return ( {
+ {showEmailGroupSection && } +
diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx new file mode 100644 index 0000000000..78eab4ab96 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail.tsx @@ -0,0 +1,151 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails'; +import { useDeleteEmailGroupChannel } from '@/settings/accounts/hooks/useDeleteEmailGroupChannel'; +import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +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 { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +const DELETE_EMAIL_GROUP_MODAL_ID = 'delete-email-group-channel-modal'; + +const StyledForwardingRow = styled.div` + display: flex; + flex-direction: row; +`; + +const StyledForwardingInputContainer = styled.div` + flex: 1; + margin-right: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsWorkspaceEmailGroupChannelDetail = () => { + const { t } = useLingui(); + const navigate = useNavigate(); + const { messageChannelId } = useParams<{ messageChannelId: string }>(); + const { channels, loading } = useMyMessageChannels(); + const { copyToClipboard } = useCopyToClipboard(); + const { openModal } = useModal(); + const { enqueueErrorSnackBar } = useSnackBar(); + const { deleteEmailGroupChannel, loading: deleting } = + useDeleteEmailGroupChannel(); + + if (loading) { + return null; + } + + const channel = channels.find( + (channel) => + channel.id === messageChannelId && + channel.type === MessageChannelType.EMAIL_GROUP, + ); + + if (!isDefined(channel)) { + return ; + } + + const sourceHandle = channel.connectedAccount?.handle ?? channel.handle; + const forwardingAddress = channel.handle; + + const handleDelete = async () => { + try { + await deleteEmailGroupChannel(channel.id); + navigate(-1); + } catch { + enqueueErrorSnackBar({ + message: t`Failed to delete email group channel.`, + }); + } + }; + + return ( + openModal(DELETE_EMAIL_GROUP_MODAL_ID)} + /> + } + > + +
+ + +
+
+ + + + + +
+ +
+ +
+ ); +}; diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index f2a4c3988b..04f4e3d346 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -53,6 +53,7 @@ export const mockedClientConfig: ClientConfig = { isAttachmentPreviewEnabled: true, isConfigVariablesInDbEnabled: false, isImapSmtpCaldavEnabled: false, + isEmailGroupEnabled: false, isTwoFactorAuthenticationEnabled: false, isEmailingDomainsEnabled: false, allowRequestsToTwentyIcons: true, diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index ee2efa1244..ae441dce57 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -176,6 +176,7 @@ "rxjs": "7.8.1", "semver": "7.6.3", "sharp": "0.32.6", + "sns-payload-validator": "^2.1.0", "stripe": "19.3.1", "tar": "^7.5.9", "temporal-polyfill": "^0.3.0", diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type.ts new file mode 100644 index 0000000000..378be65e7e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type.ts @@ -0,0 +1,35 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.4.0', 1778256809018) +export class AddEmailGroupChannelTypeFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TYPE "core"."messageChannel_type_enum" RENAME TO "messageChannel_type_enum_old"', + ); + await queryRunner.query( + "CREATE TYPE \"core\".\"messageChannel_type_enum\" AS ENUM('EMAIL', 'SMS', 'EMAIL_GROUP')", + ); + await queryRunner.query( + 'ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum" USING "type"::"text"::"core"."messageChannel_type_enum"', + ); + await queryRunner.query('DROP TYPE "core"."messageChannel_type_enum_old"'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TYPE "core"."messageChannel_type_enum_old" AS ENUM(\'EMAIL\', \'SMS\')', + ); + await queryRunner.query( + 'ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum_old" USING "type"::"text"::"core"."messageChannel_type_enum_old"', + ); + await queryRunner.query('DROP TYPE "core"."messageChannel_type_enum"'); + await queryRunner.query( + 'ALTER TYPE "core"."messageChannel_type_enum_old" RENAME TO "messageChannel_type_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 b30db5aa54..8b1a8d8e2f 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 @@ -29,6 +29,7 @@ import { TransformApplicationVariableToSyncableEntityFastInstanceCommand } from import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1797000001000-add-tool-and-workflow-action-trigger-settings'; import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier'; import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings'; +import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -60,4 +61,5 @@ export const INSTANCE_COMMANDS = [ RemoveUserDefaultAvatarUrlFastInstanceCommand, TransformApplicationVariableToSyncableEntityFastInstanceCommand, BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand, + AddEmailGroupChannelTypeFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 18a9b9057c..c08d1c6b2a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -1140,6 +1140,7 @@ export class AuthService { return []; case ConnectedAccountProvider.IMAP_SMTP_CALDAV: return []; + case ConnectedAccountProvider.EMAIL_GROUP: case ConnectedAccountProvider.APP: return []; default: diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 50ae996709..63036640dc 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -94,6 +94,7 @@ describe('ClientConfigController', () => { isGoogleCalendarEnabled: false, isConfigVariablesInDbEnabled: false, isImapSmtpCaldavEnabled: false, + isEmailGroupEnabled: false, calendarBookingPageId: undefined, isTwoFactorAuthenticationEnabled: false, allowRequestsToTwentyIcons: true, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index a263bee82f..37bfcd50d6 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -309,6 +309,9 @@ export class ClientConfig { @Field(() => Boolean) isImapSmtpCaldavEnabled: boolean; + @Field(() => Boolean) + isEmailGroupEnabled: boolean; + @Field(() => Boolean) allowRequestsToTwentyIcons: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 0dd06442b5..c404d14165 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -171,6 +171,7 @@ describe('ClientConfigService', () => { isGoogleCalendarEnabled: true, isConfigVariablesInDbEnabled: false, isImapSmtpCaldavEnabled: false, + isEmailGroupEnabled: false, allowRequestsToTwentyIcons: false, calendarBookingPageId: 'team/twenty/talk-to-us', isCloudflareIntegrationEnabled: false, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index a9ab34f1d7..3c70b5b13d 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -4,6 +4,8 @@ import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { type AiSdkPackage } from 'twenty-shared/ai'; +import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface'; + import { AI_SDK_ANTHROPIC, AI_SDK_BEDROCK, @@ -247,6 +249,10 @@ export class ClientConfigService { isImapSmtpCaldavEnabled: this.twentyConfigService.get( 'IS_IMAP_SMTP_CALDAV_ENABLED', ), + isEmailGroupEnabled: + this.twentyConfigService.get('STORAGE_TYPE') === + StorageDriverType.S_3 && + isNonEmptyString(this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN')), allowRequestsToTwentyIcons: this.twentyConfigService.get( 'ALLOW_REQUESTS_TO_TWENTY_ICONS', ), diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index d8015ea445..7eff83c0d6 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -47,6 +47,7 @@ import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logi import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module'; import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory'; import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module'; +import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module'; @@ -89,6 +90,7 @@ import { FileModule } from './file/file.module'; AuthModule, BillingModule, BillingWebhookModule, + MessagingWebhooksModule, UsageModule, ClientConfigModule, FeatureFlagModule, 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 new file mode 100644 index 0000000000..6faaceb0a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.controller.ts @@ -0,0 +1,67 @@ +import { + BadRequestException, + Controller, + HttpCode, + Post, + type RawBodyRequest, + Req, + 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 { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; + +type SnsPayload = SnsPayloadValidator.SnsPayload; + +@Controller() +export class MessagingWebhooksController { + constructor( + private readonly snsSignatureVerifierService: SnsSignatureVerifierService, + private readonly messagingWebhookDispatcherService: MessagingWebhookDispatcherService, + ) {} + + @Post(['webhooks/messaging/ses']) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + @HttpCode(200) + async handleSesWebhook( + @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, + ); + } + } + + private parseSnsPayload(rawBody: Buffer): SnsPayload { + try { + return JSON.parse(rawBody.toString('utf8')) as SnsPayload; + } catch { + throw new BadRequestException('Invalid SNS payload'); + } + } +} 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 new file mode 100644 index 0000000000..713ad18c3c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/messaging-webhooks.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +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 { SnsSignatureVerifierService } from 'src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service'; +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; + +@Module({ + imports: [TwentyConfigModule], + controllers: [MessagingWebhooksController], + providers: [SnsSignatureVerifierService, MessagingWebhookDispatcherService], +}) +export class MessagingWebhooksModule {} 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 new file mode 100644 index 0000000000..5211f8be54 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/messaging-webhook-dispatcher.service.ts @@ -0,0 +1,99 @@ +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/sns-signature-verifier.service.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts new file mode 100644 index 0000000000..c46b8a4b52 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/services/sns-signature-verifier.service.ts @@ -0,0 +1,50 @@ +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; + +import SnsPayloadValidator from 'sns-payload-validator'; + +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +type SnsPayload = SnsPayloadValidator.SnsPayload; + +@Injectable() +export class SnsSignatureVerifierService { + private readonly logger = new Logger(SnsSignatureVerifierService.name); + private readonly validator = new SnsPayloadValidator(); + + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + async assertAllowedAndSigned(payload: SnsPayload): Promise { + if (!this.isTopicAllowlisted(payload.TopicArn)) { + this.logger.warn(`SNS topic ${payload.TopicArn} is not in allowlist`); + + throw new ForbiddenException('SNS topic not allowed'); + } + + try { + await this.validator.validate(payload); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.warn(`SNS signature verification failed: ${errorMessage}`); + + throw new ForbiddenException('SNS signature invalid'); + } + } + + private isTopicAllowlisted(topicArn: string): boolean { + const allowlist = this.twentyConfigService.get( + 'SES_SNS_TOPIC_ARN_ALLOWLIST', + ); + + if (typeof allowlist !== 'string' || allowlist.trim() === '') { + return false; + } + + return allowlist + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .includes(topicArn); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/sns-message.type.ts b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/sns-message.type.ts new file mode 100644 index 0000000000..3e908a61de --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/messaging-webhooks/types/sns-message.type.ts @@ -0,0 +1,5 @@ +import { type SESMessage } from 'aws-lambda'; + +export type SesInboundNotification = SESMessage & { + notificationType?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 6acee25d50..dba1bcb8ed 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1652,6 +1652,24 @@ export class ConfigVariables { @IsOptional() AWS_SES_ACCOUNT_ID: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.AWS_SES_SETTINGS, + description: + 'Domain used for email group inbound mail (the right-hand side of ch_xxx@). Required to enable email group channels.', + type: ConfigVariableType.STRING, + }) + @IsOptional() + INBOUND_EMAIL_DOMAIN: string; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.AWS_SES_SETTINGS, + description: + 'Comma-separated list of SNS topic ARNs accepted by the inbound-email webhook (e.g. arn:aws:sns:us-east-1:123:my-inbound).', + type: ConfigVariableType.STRING, + }) + @IsOptional() + SES_SNS_TOPIC_ARN_ALLOWLIST: string; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ADVANCED_SETTINGS, description: 'Timeout in milliseconds for primary database queries', diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index bde4fc4da7..e05e8b8ea4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should match snapshot 1`] = ` { diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input.ts new file mode 100644 index 0000000000..da78a36b77 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input.ts @@ -0,0 +1,12 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +@InputType('CreateEmailGroupChannelInput') +export class CreateEmailGroupChannelInput { + @Field() + @IsString() + @IsNotEmpty() + @IsEmail() + @MaxLength(254) + handle: string; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output.ts new file mode 100644 index 0000000000..27c5cad436 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto'; + +@ObjectType('CreateEmailGroupChannelOutput') +export class CreateEmailGroupChannelOutput { + @Field(() => MessageChannelDTO) + messageChannel: MessageChannelDTO; + + @Field() + forwardingAddress: 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 f52d014db0..39499732c9 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 @@ -1,21 +1,33 @@ +import { randomBytes } from 'crypto'; + import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isNonEmptyString } from '@sniptt/guards'; import { In, Repository } from 'typeorm'; import { + ConnectedAccountProvider, + MessageChannelContactAutoCreationPolicy, + MessageChannelPendingGroupEmailsAction, MessageChannelSyncStage, + MessageChannelSyncStatus, MessageChannelType, MessageChannelVisibility, } from 'twenty-shared/types'; +import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service'; +import { CreateEmailGroupChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output'; import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto'; import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { MessageChannelException, MessageChannelExceptionCode, } from 'src/engine/metadata-modules/message-channel/message-channel.exception'; +import { INBOUND_EMAIL_LOCAL_PART_PREFIX } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant'; +import { INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant'; @Injectable() export class MessageChannelMetadataService { @@ -23,6 +35,7 @@ export class MessageChannelMetadataService { @InjectRepository(MessageChannelEntity) private readonly repository: Repository, private readonly connectedAccountMetadataService: ConnectedAccountMetadataService, + private readonly twentyConfigService: TwentyConfigService, ) {} async findAll(workspaceId: string): Promise { @@ -172,6 +185,65 @@ export class MessageChannelMetadataService { return this.repository.findOneOrFail({ where: { id, workspaceId } }); } + async createEmailGroupChannel({ + handle, + userWorkspaceId, + workspaceId, + }: { + handle: string; + userWorkspaceId: string; + workspaceId: string; + }): Promise { + const inboundEmailDomain = this.twentyConfigService.get( + 'INBOUND_EMAIL_DOMAIN', + ); + const storageType = this.twentyConfigService.get('STORAGE_TYPE'); + + if ( + !isNonEmptyString(inboundEmailDomain) || + storageType !== StorageDriverType.S_3 + ) { + throw new MessageChannelException( + 'Email group is not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3', + MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED, + ); + } + + const localPart = + INBOUND_EMAIL_LOCAL_PART_PREFIX + + randomBytes(INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES).toString('hex'); + + const forwardingAddress = `${localPart}@${inboundEmailDomain}`; + + const connectedAccount = await this.connectedAccountMetadataService.create({ + workspaceId, + handle, + provider: ConnectedAccountProvider.EMAIL_GROUP, + userWorkspaceId, + accessToken: null, + refreshToken: null, + }); + + const messageChannel = await this.create({ + workspaceId, + handle: forwardingAddress, + connectedAccountId: connectedAccount.id, + type: MessageChannelType.EMAIL_GROUP, + visibility: MessageChannelVisibility.SHARE_EVERYTHING, + syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + syncStatus: MessageChannelSyncStatus.ACTIVE, + isSyncEnabled: true, + isContactAutoCreationEnabled: true, + contactAutoCreationPolicy: + MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED, + excludeGroupEmails: false, + excludeNonProfessionalEmails: false, + pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction.NONE, + }); + + return { messageChannel, forwardingAddress }; + } + async delete({ id, workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel.exception.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel.exception.ts index bf07628b61..2b55a4135f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/message-channel.exception.ts @@ -8,6 +8,7 @@ export enum MessageChannelExceptionCode { MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND', INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT', MESSAGE_CHANNEL_OWNERSHIP_VIOLATION = 'MESSAGE_CHANNEL_OWNERSHIP_VIOLATION', + EMAIL_GROUP_NOT_CONFIGURED = 'EMAIL_GROUP_NOT_CONFIGURED', } const getMessageChannelExceptionUserFriendlyMessage = ( @@ -20,6 +21,8 @@ const getMessageChannelExceptionUserFriendlyMessage = ( return msg`Invalid message channel input.`; case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION: return msg`You do not have access to this message channel.`; + case MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED: + return msg`Email group is not configured on this server.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver.ts index daf9070112..441a8f3556 100644 --- a/packages/twenty-server/src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver.ts @@ -1,5 +1,5 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; -import { Args, Mutation, Query } from '@nestjs/graphql'; +import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; @@ -9,6 +9,7 @@ import { Not, Repository } from 'typeorm'; import { MessageChannelPendingGroupEmailsAction, MessageChannelSyncStage, + MessageChannelType, MessageFolderPendingSyncAction, } from 'twenty-shared/types'; import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; @@ -19,6 +20,10 @@ import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-worksp import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { CreateEmailGroupChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input'; +import { CreateEmailGroupChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output'; +import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service'; +import { ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto'; import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto'; import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input'; import { @@ -36,11 +41,40 @@ import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/ export class MessageChannelResolver { constructor( private readonly messageChannelMetadataService: MessageChannelMetadataService, + private readonly connectedAccountMetadataService: ConnectedAccountMetadataService, @InjectRepository(MessageFolderEntity) private readonly messageFolderRepository: Repository, private readonly messagingProcessGroupEmailActionsService: MessagingProcessGroupEmailActionsService, ) {} + @ResolveField('connectedAccount', () => ConnectedAccountPublicDTO, { + nullable: true, + }) + async connectedAccount( + @Parent() messageChannel: MessageChannelDTO, + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, + ): Promise { + const connectedAccount = + await this.connectedAccountMetadataService.findById({ + id: messageChannel.connectedAccountId, + workspaceId: workspace.id, + }); + + if (!isDefined(connectedAccount)) { + return null; + } + + if ( + messageChannel.type !== MessageChannelType.EMAIL_GROUP && + connectedAccount.userWorkspaceId !== userWorkspaceId + ) { + return null; + } + + return connectedAccount; + } + @Query(() => [MessageChannelDTO]) @UseGuards(NoPermissionGuard) async myMessageChannels( @@ -130,4 +164,47 @@ export class MessageChannelResolver { data: input.update, }); } + + @Mutation(() => CreateEmailGroupChannelOutput) + @UseGuards(NoPermissionGuard) + async createEmailGroupChannel( + @Args('input') input: CreateEmailGroupChannelInput, + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, + ): Promise { + return this.messageChannelMetadataService.createEmailGroupChannel({ + handle: input.handle, + userWorkspaceId, + workspaceId: workspace.id, + }); + } + + @Mutation(() => MessageChannelDTO) + @UseGuards(NoPermissionGuard) + async deleteEmailGroupChannel( + @Args('id', { type: () => UUIDScalarType }) id: string, + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, + ): Promise { + const messageChannel = + await this.messageChannelMetadataService.verifyOwnership({ + id, + userWorkspaceId, + workspaceId: workspace.id, + }); + + if (messageChannel.type !== MessageChannelType.EMAIL_GROUP) { + throw new MessageChannelException( + `Message channel ${id} is not an email group`, + MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT, + ); + } + + await this.connectedAccountMetadataService.delete({ + id: messageChannel.connectedAccountId, + workspaceId: workspace.id, + }); + + return messageChannel; + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts index c60e3c7ae9..f6de0151b4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts @@ -2,6 +2,7 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { ForbiddenError, + InternalServerError, NotFoundError, UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -23,6 +24,8 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => { throw new UserInputError(error); case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION: throw new ForbiddenError(error); + case MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED: + throw new InternalServerError(error); default: { return assertUnreachable(error.code); } 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 74b53970f7..c27ef5cdf4 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 @@ -237,6 +237,7 @@ describe('WorkspaceEntityManager', () => { IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false, IS_PUBLIC_DOMAIN_ENABLED: false, IS_EMAILING_DOMAIN_ENABLED: false, + IS_EMAIL_GROUP_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, IS_CONNECTED_ACCOUNT_MIGRATED: false, IS_RICH_TEXT_V1_MIGRATED: false, 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 a4f9015fea..de6091ac8f 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 @@ -35,6 +35,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util.ts index 034f74548d..065a0fe07c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util.ts @@ -286,6 +286,13 @@ export const buildMessageChannelStandardFlatFieldMetadatas = ({ position: 1, color: 'blue', }, + { + id: '20202020-7f22-4e58-aa33-9c3e2c72ab10', + value: MessageChannelType.EMAIL_GROUP, + label: i18nLabel(msg`Email group`), + position: 2, + color: 'turquoise', + }, ], }, standardObjectMetadataRelatedEntityIds, diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 3190a50953..d1a5b83d24 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -68,6 +68,7 @@ const bootstrap = async () => { limit: settings.storage.maxFileSize, extended: true, }); + app.useBodyParser('text', { type: 'text/plain', limit: '1024kb' }); // Graphql file upload app.use( diff --git a/packages/twenty-server/src/modules/connected-account/channel-sync/services/channel-sync.service.ts b/packages/twenty-server/src/modules/connected-account/channel-sync/services/channel-sync.service.ts index 3355781fb5..d007fe49a6 100644 --- a/packages/twenty-server/src/modules/connected-account/channel-sync/services/channel-sync.service.ts +++ b/packages/twenty-server/src/modules/connected-account/channel-sync/services/channel-sync.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { CalendarChannelSyncStage, CalendarChannelSyncStatus, MessageChannelSyncStage, + MessageChannelType, } from 'twenty-shared/types'; 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'; @@ -63,6 +64,7 @@ export class ChannelSyncService { where: { connectedAccountId, syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION, + type: Not(MessageChannelType.EMAIL_GROUP), workspaceId, }, }); diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts index 0b756a837f..16bea3cca8 100644 --- a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts +++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts @@ -43,6 +43,7 @@ export class EmailAliasManagerService { case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.EMAIL_GROUP: case ConnectedAccountProvider.APP: handleAliases = []; break; diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts index cab9e4f460..ca7fb9d30f 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts @@ -120,6 +120,7 @@ export class ConnectedAccountRefreshTokensService { case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.EMAIL_GROUP: return true; default: return assertUnreachable( @@ -152,6 +153,7 @@ export class ConnectedAccountRefreshTokensService { case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.EMAIL_GROUP: throw new ConnectedAccountRefreshAccessTokenException( `Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`, ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED, diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job.ts index 4e83cb4913..415bc228e2 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job.ts @@ -2,9 +2,12 @@ import { Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; -import { MessageChannelSyncStage } from 'twenty-shared/types'; +import { + MessageChannelSyncStage, + MessageChannelType, +} from 'twenty-shared/types'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; @@ -57,6 +60,7 @@ export class MessagingMessageListFetchCronJob { workspaceId: activeWorkspace.id, isSyncEnabled: true, syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + type: Not(MessageChannelType.EMAIL_GROUP), }, }, ); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job.ts index 02db9699a6..3175521b9f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job.ts @@ -3,9 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; -import { MessageChannelSyncStage } from 'twenty-shared/types'; +import { + MessageChannelSyncStage, + MessageChannelType, +} from 'twenty-shared/types'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; @@ -58,6 +61,7 @@ export class MessagingMessagesImportCronJob { workspaceId: activeWorkspace.id, isSyncEnabled: true, syncStage: MessageChannelSyncStage.MESSAGES_IMPORT_PENDING, + type: Not(MessageChannelType.EMAIL_GROUP), }, }, ); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job.ts index 3e588ec638..eb2cb2101f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job.ts @@ -1,11 +1,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { MessageChannelSyncStage, MessageChannelSyncStatus, + MessageChannelType, } from 'twenty-shared/types'; import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; @@ -61,6 +62,7 @@ export class MessagingRelaunchFailedMessageChannelsCronJob { where: { syncStage: MessageChannelSyncStage.FAILED, syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN, + type: Not(MessageChannelType.EMAIL_GROUP), workspaceId: In(activeWorkspaceIds), }, }) diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service.ts index b61d16b36b..e6f24008bf 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service.ts @@ -1,8 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { type ImapFlow } from 'imapflow'; -import { Address, type Email as ParsedMail } from 'postal-mime'; -import { MessageParticipantRole } from 'twenty-shared/types'; +import { type Email as ParsedMail } from 'postal-mime'; import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util'; @@ -11,9 +10,10 @@ import { ImapMessageParserService } from 'src/modules/messaging/message-import-m import { ImapMessageTextExtractorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service'; import { ImapMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-messages-import-error-handler.service'; import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util'; -import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message'; -import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; +import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util'; +import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util'; +import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util'; import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util'; type ConnectedAccount = Pick< @@ -164,7 +164,7 @@ export class ImapGetMessagesService { folderExternalId: string, connectedAccount: Pick, ): MessageWithParticipants { - const fromAddresses = this.extractAddresses(parsed.from); + const fromAddresses = extractAddressesFromParsedEmail(parsed.from); const senderAddress = fromAddresses[0]?.address ?? ''; const text = sanitizeString( @@ -173,75 +173,17 @@ export class ImapGetMessagesService { return { externalId: `${folderPath}:${uid}`, - messageThreadExternalId: this.extractThreadId(parsed), + messageThreadExternalId: extractThreadIdFromParsedEmail(parsed), headerMessageId: parsed.messageId || String(uid), subject: sanitizeString(parsed.subject || ''), text, receivedAt: parsed.date ? new Date(parsed.date) : null, direction: computeMessageDirection(senderAddress, connectedAccount), - attachments: this.extractAttachments(parsed), - participants: this.extractParticipants(parsed), + attachments: (parsed.attachments || []).map((attachment) => ({ + filename: attachment.filename || 'unnamed-attachment', + })), + participants: extractParticipantsFromParsedEmail(parsed), messageFolderExternalIds: [folderExternalId], }; } - - private extractThreadId(parsed: ParsedMail): string { - if (Array.isArray(parsed.references) && parsed.references[0]?.trim()) { - return parsed.references[0].trim(); - } - - if (parsed.inReplyTo) { - const inReplyTo = String(parsed.inReplyTo).trim(); - - if (inReplyTo) { - return inReplyTo; - } - } - - if (parsed.messageId?.trim()) { - return parsed.messageId.trim(); - } - - return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - } - - private extractParticipants(parsed: ParsedMail) { - const addressFields = [ - { field: parsed.from, role: MessageParticipantRole.FROM }, - { field: parsed.to, role: MessageParticipantRole.TO }, - { field: parsed.cc, role: MessageParticipantRole.CC }, - { field: parsed.bcc, role: MessageParticipantRole.BCC }, - ] as const; - - return addressFields.flatMap(({ field, role }) => - formatAddressObjectAsParticipants(this.extractAddresses(field), role), - ); - } - - private extractAddresses( - address: Address | Address[] | undefined, - ): EmailAddress[] { - if (!address) { - return []; - } - - const addresses = Array.isArray(address) ? address : [address]; - - const mailboxes = addresses.flatMap((addr) => - addr.address ? [addr] : (addr.group ?? []), - ); - - return mailboxes - .filter((mailbox) => mailbox.address) - .map((mailbox) => ({ - address: mailbox.address, - name: sanitizeString(mailbox.name || ''), - })); - } - - private extractAttachments(parsed: ParsedMail) { - return (parsed.attachments || []).map((attachment) => ({ - filename: attachment.filename || 'unnamed-attachment', - })); - } } diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant.ts new file mode 100644 index 0000000000..c4f95d4538 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant.ts @@ -0,0 +1 @@ +export const INBOUND_EMAIL_LOCAL_PART_PREFIX = 'ch_'; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant.ts new file mode 100644 index 0000000000..814ad90ff3 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant.ts @@ -0,0 +1 @@ +export const INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES = 6; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module.ts new file mode 100644 index 0000000000..c76f61c64b --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider'; +import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service'; +import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service'; + +@Module({ + imports: [ + TwentyConfigModule, + WorkspaceDataSourceModule, + TypeOrmModule.forFeature([MessageChannelEntity, ConnectedAccountEntity]), + ], + providers: [ + InboundEmailS3ClientProvider, + InboundEmailStorageService, + InboundEmailParserService, + ], + exports: [ + InboundEmailS3ClientProvider, + InboundEmailStorageService, + InboundEmailParserService, + ], +}) +export class MessagingInboundEmailDriverModule {} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider.ts new file mode 100644 index 0000000000..2ea87cd54d --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; + +import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3'; +import { isNonEmptyString } from '@sniptt/guards'; + +import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class InboundEmailS3ClientProvider { + private s3Client: S3Client | null = null; + + constructor(private readonly twentyConfigService: TwentyConfigService) {} + + isConfigured(): boolean { + const storageType = this.twentyConfigService.get('STORAGE_TYPE'); + const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN'); + + return storageType === StorageDriverType.S_3 && isNonEmptyString(domain); + } + + getBucket(): string { + const bucket = this.twentyConfigService.get('STORAGE_S3_NAME'); + + if (!isNonEmptyString(bucket)) { + throw new Error( + 'STORAGE_S3_NAME is not configured; email group requires S3 storage.', + ); + } + + return bucket; + } + + getDomain(): string { + const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN'); + + if (!isNonEmptyString(domain)) { + throw new Error( + 'INBOUND_EMAIL_DOMAIN is not configured; email group is disabled.', + ); + } + + return domain; + } + + getClient(): S3Client { + if (this.s3Client) { + return this.s3Client; + } + + const region = this.twentyConfigService.get('STORAGE_S3_REGION'); + + if (!isNonEmptyString(region)) { + throw new Error('STORAGE_S3_REGION must be set to use email group.'); + } + + const config: S3ClientConfig = { region }; + + const endpoint = this.twentyConfigService.get('STORAGE_S3_ENDPOINT'); + + if (isNonEmptyString(endpoint)) { + config.endpoint = endpoint; + } + + const accessKeyId = this.twentyConfigService.get( + 'STORAGE_S3_ACCESS_KEY_ID', + ); + const secretAccessKey = this.twentyConfigService.get( + 'STORAGE_S3_SECRET_ACCESS_KEY', + ); + + if (isNonEmptyString(accessKeyId) && isNonEmptyString(secretAccessKey)) { + config.credentials = { accessKeyId, secretAccessKey }; + } + + this.s3Client = new S3Client(config); + + return this.s3Client; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service.ts new file mode 100644 index 0000000000..0e5bc87b30 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { MessageChannelType } from 'twenty-shared/types'; +import { Repository } from 'typeorm'; + +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; +import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider'; +import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service'; +import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service'; +import { type InboundEmailImportOutcome } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type'; +import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service'; +import { isDefined } from 'twenty-shared/utils'; + +type ImportInboundMessageParams = { + s3Key: string; + envelopeRecipients: string[]; +}; + +@Injectable() +export class InboundEmailImportService { + private readonly logger = new Logger(InboundEmailImportService.name); + + constructor( + private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider, + private readonly inboundEmailStorageService: InboundEmailStorageService, + private readonly inboundEmailParserService: InboundEmailParserService, + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly messagingSaveMessagesAndEnqueueContactCreationService: MessagingSaveMessagesAndEnqueueContactCreationService, + @InjectRepository(MessageChannelEntity) + private readonly messageChannelRepository: Repository, + @InjectRepository(ConnectedAccountEntity) + private readonly connectedAccountRepository: Repository, + ) {} + + async importInboundMessage( + params: ImportInboundMessageParams, + ): Promise { + const { s3Key, envelopeRecipients } = params; + + if (!this.inboundEmailS3ClientProvider.isConfigured()) { + this.logger.warn( + `Skipping inbound email import for ${s3Key}: email group is not configured.`, + ); + + return { kind: 'unconfigured' }; + } + + const inboundEmailDomain = this.inboundEmailS3ClientProvider.getDomain(); + const recipient = this.matchInboundRecipient( + envelopeRecipients, + inboundEmailDomain, + ); + + if (!isDefined(recipient)) { + this.logger.warn( + `No recipient at ${inboundEmailDomain} in SNS payload for ${s3Key}`, + ); + + return { kind: 'unmatched', recipient: null }; + } + + const messageChannel = await this.messageChannelRepository.findOne({ + where: { handle: recipient, type: MessageChannelType.EMAIL_GROUP }, + }); + + if (!isDefined(messageChannel)) { + this.logger.warn( + `No email group channel matches recipient ${recipient} (key ${s3Key})`, + ); + + return { kind: 'unmatched', recipient }; + } + + const rawMessage = + await this.inboundEmailStorageService.getRawMessage(s3Key); + const parsedInboundMessage = await this.inboundEmailParserService.parse( + rawMessage, + s3Key, + ); + + const { workspaceId } = messageChannel; + + const connectedAccount = await this.connectedAccountRepository.findOne({ + where: { id: messageChannel.connectedAccountId, workspaceId }, + }); + + if (!isDefined(connectedAccount)) { + throw new Error( + `Email group channel ${messageChannel.id} has no connected account`, + ); + } + + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { + await this.messagingSaveMessagesAndEnqueueContactCreationService.saveMessagesAndEnqueueContactCreation( + [parsedInboundMessage.message], + messageChannel, + connectedAccount, + workspaceId, + ); + }, buildSystemAuthContext(workspaceId)); + + await this.inboundEmailStorageService.deleteRawMessage(s3Key); + + return { + kind: 'imported', + workspaceId, + messageChannelId: messageChannel.id, + }; + } + + private matchInboundRecipient( + envelopeRecipients: string[], + inboundEmailDomain: string, + ): string | null { + const normalizedDomain = inboundEmailDomain.toLowerCase(); + + return ( + envelopeRecipients + .map((address) => address.toLowerCase()) + .find((address) => address.endsWith(`@${normalizedDomain}`)) ?? null + ); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service.ts new file mode 100644 index 0000000000..f7265027c7 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import PostalMime, { type Email as ParsedEmail } from 'postal-mime'; + +import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum'; +import { type ParsedInboundMessage } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type'; +import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message'; +import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util'; +import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util'; +import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util'; + +@Injectable() +export class InboundEmailParserService { + async parse( + rawMessage: Buffer, + s3Key: string, + ): Promise { + const parsedEmail = await PostalMime.parse(rawMessage); + const message = this.buildMessage(parsedEmail, s3Key); + + return { parsed: parsedEmail, message }; + } + + private buildMessage( + parsedEmail: ParsedEmail, + s3Key: string, + ): MessageWithParticipants { + return { + externalId: `inbound-email:${s3Key}`, + messageThreadExternalId: extractThreadIdFromParsedEmail(parsedEmail), + headerMessageId: parsedEmail.messageId?.trim() || `inbound-${s3Key}`, + subject: sanitizeString(parsedEmail.subject || ''), + text: sanitizeString(parsedEmail.text || ''), + receivedAt: parsedEmail.date ? new Date(parsedEmail.date) : new Date(), + direction: MessageDirection.INCOMING, + attachments: [], + participants: extractParticipantsFromParsedEmail(parsedEmail), + }; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service.ts new file mode 100644 index 0000000000..2ee1a6851b --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { Readable } from 'stream'; + +import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider'; +import { isDefined } from 'twenty-shared/utils'; + +@Injectable() +export class InboundEmailStorageService { + constructor( + private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider, + ) {} + + async getRawMessage(s3Key: string): Promise { + const client = this.inboundEmailS3ClientProvider.getClient(); + const bucket = this.inboundEmailS3ClientProvider.getBucket(); + + const response = await client.send( + new GetObjectCommand({ Bucket: bucket, Key: s3Key }), + ); + + if (!isDefined(response.Body)) { + throw new Error(`S3 object ${s3Key} has no body`); + } + + const stream = response.Body as Readable; + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + + async deleteRawMessage(s3Key: string): Promise { + const client = this.inboundEmailS3ClientProvider.getClient(); + const bucket = this.inboundEmailS3ClientProvider.getBucket(); + + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: s3Key })); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type.ts new file mode 100644 index 0000000000..6e7acbf0db --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type.ts @@ -0,0 +1,4 @@ +export type InboundEmailImportOutcome = + | { kind: 'imported'; workspaceId: string; messageChannelId: string } + | { kind: 'unmatched'; recipient: string | null } + | { kind: 'unconfigured' }; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type.ts new file mode 100644 index 0000000000..41023ccb1e --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type.ts @@ -0,0 +1,8 @@ +import { type Email as ParsedEmail } from 'postal-mime'; + +import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message'; + +export type ParsedInboundMessage = { + parsed: ParsedEmail; + message: MessageWithParticipants; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job.ts new file mode 100644 index 0000000000..f36b8a3598 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job.ts @@ -0,0 +1,37 @@ +import { Logger, Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service'; + +export type MessagingInboundEmailImportJobData = { + s3Key: string; + envelopeRecipients: string[]; +}; + +@Processor({ + queueName: MessageQueue.messagingQueue, + scope: Scope.REQUEST, +}) +export class MessagingInboundEmailImportJob { + private readonly logger = new Logger(MessagingInboundEmailImportJob.name); + + constructor( + private readonly inboundEmailImportService: InboundEmailImportService, + ) {} + + @Process(MessagingInboundEmailImportJob.name) + async handle(data: MessagingInboundEmailImportJobData): Promise { + const { s3Key, envelopeRecipients } = data; + + const outcome = await this.inboundEmailImportService.importInboundMessage({ + s3Key, + envelopeRecipients, + }); + + this.logger.log( + `Inbound email import outcome for ${s3Key}: ${outcome.kind}`, + ); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts index 14db1a674f..37b6397074 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/messaging-import-manager.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -28,10 +30,13 @@ import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-impo import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job'; import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module'; import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module'; +import { MessagingInboundEmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module'; +import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service'; import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module'; import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module'; import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job'; import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache'; +import { MessagingInboundEmailImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job'; import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job'; import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job'; import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job'; @@ -50,6 +55,7 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service'; import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service'; import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service'; +import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module'; import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module'; import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module'; @Module({ @@ -61,13 +67,16 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingMicrosoftDriverModule, MessagingIMAPDriverModule, MessagingSmtpDriverModule, + MessagingInboundEmailDriverModule, MessagingCommonModule, + TwentyConfigModule, TypeOrmModule.forFeature([ WorkspaceEntity, ObjectMetadataEntity, MessageChannelEntity, MessageFolderEntity, UserWorkspaceEntity, + ConnectedAccountEntity, ]), EmailAliasManagerModule, FeatureFlagModule, @@ -77,6 +86,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingMessageCleanerModule, WorkspaceEventEmitterModule, ConnectedAccountModule, + MessagingWebhooksModule, ], providers: [ MessagingMessageListFetchCronCommand, @@ -95,6 +105,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingRelaunchFailedMessageChannelsCronJob, MessagingAddSingleMessageToCacheForImportJob, MessagingCleanCacheJob, + MessagingInboundEmailImportJob, MessagingMessageService, MessagingMessageFolderAssociationService, MessagingMessageListFetchService, @@ -109,6 +120,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingProcessGroupEmailActionsService, MessagingDeleteFolderMessagesService, MessagingDeleteGroupEmailMessagesService, + InboundEmailImportService, ], exports: [ MessagingAccountAuthenticationService, @@ -117,6 +129,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessagingOngoingStaleCronCommand, MessagingRelaunchFailedMessageChannelsCronCommand, MessagingProcessGroupEmailActionsService, + InboundEmailImportService, MessagingSaveMessagesAndEnqueueContactCreationService, ], }) diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util.ts new file mode 100644 index 0000000000..9140df6859 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util.ts @@ -0,0 +1,25 @@ +import { type Address } from 'postal-mime'; + +import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address'; +import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util'; + +export const extractAddressesFromParsedEmail = ( + address: Address | Address[] | undefined, +): EmailAddress[] => { + if (!address) { + return []; + } + + const addresses = Array.isArray(address) ? address : [address]; + + const mailboxes = addresses.flatMap((addr) => + addr.address ? [addr] : (addr.group ?? []), + ); + + return mailboxes + .filter((mailbox) => mailbox.address) + .map((mailbox) => ({ + address: mailbox.address, + name: sanitizeString(mailbox.name || ''), + })); +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts new file mode 100644 index 0000000000..882cb1e583 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util.ts @@ -0,0 +1,21 @@ +import { type Email as ParsedEmail } from 'postal-mime'; +import { MessageParticipantRole } from 'twenty-shared/types'; + +import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util'; +import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util'; + +export const extractParticipantsFromParsedEmail = (parsed: ParsedEmail) => { + const addressFields = [ + { field: parsed.from, role: MessageParticipantRole.FROM }, + { field: parsed.to, role: MessageParticipantRole.TO }, + { field: parsed.cc, role: MessageParticipantRole.CC }, + { field: parsed.bcc, role: MessageParticipantRole.BCC }, + ] as const; + + return addressFields.flatMap(({ field, role }) => + formatAddressObjectAsParticipants( + extractAddressesFromParsedEmail(field), + role, + ), + ); +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util.ts new file mode 100644 index 0000000000..422aaf3f43 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util.ts @@ -0,0 +1,35 @@ +import { type Email as ParsedEmail } from 'postal-mime'; + +export const extractThreadIdFromParsedEmail = (parsed: ParsedEmail): string => { + const references = parsed.references; + + if (typeof references === 'string' && references.trim()) { + const first = references.trim().split(/\s+/)[0]; + + if (first) { + return first; + } + } + + if (Array.isArray(references) && references.length > 0) { + const first = String(references[0]).trim(); + + if (first) { + return first; + } + } + + if (parsed.inReplyTo) { + const inReplyTo = String(parsed.inReplyTo).trim(); + + if (inReplyTo) { + return inReplyTo; + } + } + + if (parsed.messageId?.trim()) { + return parsed.messageId.trim(); + } + + return `thread-${crypto.randomUUID()}`; +}; 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 7f9b9f2249..a25b854f73 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 @@ -38,6 +38,13 @@ export class MessagingMessageOutboundService { sendMessageInput, 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.', + ); case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: case ConnectedAccountProvider.APP: @@ -72,6 +79,7 @@ export class MessagingMessageOutboundService { sendMessageInput, connectedAccount, ); + case ConnectedAccountProvider.EMAIL_GROUP: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: case ConnectedAccountProvider.APP: diff --git a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts index 33c6f219d4..4bca7f9a25 100644 --- a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts +++ b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts @@ -4,5 +4,6 @@ export enum ConnectedAccountProvider { IMAP_SMTP_CALDAV = 'imap_smtp_caldav', OIDC = 'oidc', SAML = 'saml', + EMAIL_GROUP = 'email_group', APP = 'app', } diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index 4d067dffb5..b0a1a442ae 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -6,6 +6,7 @@ export enum FeatureFlagKey { IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', 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_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED', diff --git a/packages/twenty-shared/src/types/MessageChannelType.ts b/packages/twenty-shared/src/types/MessageChannelType.ts index 8b091f6829..c9b4dfdbe0 100644 --- a/packages/twenty-shared/src/types/MessageChannelType.ts +++ b/packages/twenty-shared/src/types/MessageChannelType.ts @@ -1,4 +1,5 @@ export enum MessageChannelType { EMAIL = 'EMAIL', SMS = 'SMS', + EMAIL_GROUP = 'EMAIL_GROUP', } diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index a43fa55c95..389452bfde 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -23,6 +23,8 @@ export enum SettingsPath { WorkspaceMembersPage = 'members', WorkspaceMemberPage = 'members/:workspaceMemberId', Workspace = 'general', + EmailGroupChannelDetail = 'general/email-group/:messageChannelId', + NewEmailGroupChannel = 'general/new-email-group', Domains = 'domains', Subdomain = 'domains/subdomain', CustomDomain = 'domains/custom-domain', diff --git a/yarn.lock b/yarn.lock index 40c971f719..4ae5b399b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51487,6 +51487,15 @@ __metadata: languageName: node linkType: hard +"sns-payload-validator@npm:^2.1.0": + version: 2.1.0 + resolution: "sns-payload-validator@npm:2.1.0" + dependencies: + lru-cache: "npm:^7.14.1" + checksum: 10c0/9c1fa056fe329aff25ee1f2ec8e41b121765f45f3238f874f4513078bcf52b3b5c7bcf21ba8b86b53269562f6649db07a2472597689cea74f8dcb2f18a84f95f + languageName: node + linkType: hard + "socket.io-adapter@npm:~2.5.2": version: 2.5.5 resolution: "socket.io-adapter@npm:2.5.5" @@ -54687,6 +54696,7 @@ __metadata: rxjs: "npm:7.8.1" semver: "npm:7.6.3" sharp: "npm:0.32.6" + sns-payload-validator: "npm:^2.1.0" stripe: "npm:19.3.1" supertest: "npm:^6.1.3" tar: "npm:^7.5.9"