feat: add email forwarding message channel (#19535)

## 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 <noreply@anthropic.com>
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 <neo773@protonmail.com>
This commit is contained in:
Félix Malfait
2026-05-09 11:00:57 +02:00
committed by GitHub
parent 23aa859502
commit 4da8878697
81 changed files with 1967 additions and 309 deletions
@@ -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!
@@ -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 = {
File diff suppressed because it is too large Load Diff
@@ -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',
@@ -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<Scalars['UUID']>;
};
export type CreateEmailGroupChannelInput = {
handle: Scalars['String'];
};
export type CreateEmailGroupChannelOutput = {
__typename?: 'CreateEmailGroupChannelOutput';
forwardingAddress: Scalars['String'];
messageChannel: MessageChannel;
};
export type CreateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -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<ConnectedAccountPublicDto>;
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<NavigationMenuItem>;
@@ -2396,6 +2411,7 @@ export type Mutation = {
deleteCommandMenuItem: CommandMenuItem;
deleteConnectedAccount: ConnectedAccountDto;
deleteCurrentWorkspace: Workspace;
deleteEmailGroupChannel: MessageChannel;
deleteEmailingDomain: Scalars['Boolean'];
deleteFrontComponent: FrontComponent;
deleteManyNavigationMenuItems: Array<NavigationMenuItem>;
@@ -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<Scalars['UUID']>;
@@ -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<BarChartDataQuery, BarChartDataQueryVariables>;
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<LineChartDataQuery, LineChartDataQueryVariables>;
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<PieChartDataQuery, PieChartDataQueryVariables>;
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<CreateEmailGroupChannelMutation, CreateEmailGroupChannelMutationVariables>;
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<DeleteConnectedAccountMutation, DeleteConnectedAccountMutationVariables>;
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<DeleteEmailGroupChannelMutation, DeleteEmailGroupChannelMutationVariables>;
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<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>;
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<StartChannelSyncMutation, StartChannelSyncMutationVariables>;
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<UpdateCalendarChannelMutation, UpdateCalendarChannelMutationVariables>;
@@ -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<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
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<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
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<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
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<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
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<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
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<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
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<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
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<RotateApplicationRegistrationClientSecretMutation, RotateApplicationRegistrationClientSecretMutationVariables>;
@@ -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';
@@ -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:
@@ -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) => (
}
>
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route
path={SettingsPath.NewEmailGroupChannel}
element={<SettingsNewEmailGroupChannel />}
/>
<Route
path={SettingsPath.EmailGroupChannelDetail}
element={<SettingsWorkspaceEmailGroupChannelDetail />}
/>
<Route path={SettingsPath.Domains} element={<SettingsDomains />} />
<Route
path={SettingsPath.ApiWebhooks}
@@ -13,12 +13,13 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
@@ -100,13 +101,16 @@ export const useClientConfig = (): UseClientConfigResult => {
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,
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailGroupEnabledState = createAtomState<boolean>({
key: 'isEmailGroupEnabled',
defaultValue: false,
});
@@ -31,6 +31,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailGroupEnabled: boolean;
isEmailingDomainsEnabled: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
@@ -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 = () => {
>
<SettingsCard
Icon={<IconAt size={theme.icon.size.md} />}
title={t`Connect Account`}
title={t`Connect via IMAP/SMTP`}
/>
</UndecoratedLink>
)}
@@ -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<string, unknown>) => {
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 (
<StyledDetailsContainer>
<Section>
<H2Title
title={t`Import`}
description={t`Emails from the blocklist will be ignored. Manage blocklist on the "Accounts" setting page.`}
/>
<SettingsAccountsMessageFolderCard
onChange={handleMessageFolderImportPolicyChange}
value={messageChannel.messageFolderImportPolicy}
/>
</Section>
{supportsFolderImportPolicy && (
<Section>
<H2Title
title={t`Import`}
description={t`Emails from the blocklist will be ignored. Manage blocklist on the "Accounts" setting page.`}
/>
<SettingsAccountsMessageFolderCard
onChange={handleMessageFolderImportPolicyChange}
value={messageChannel.messageFolderImportPolicy}
/>
</Section>
)}
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
@@ -11,7 +11,10 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import React, { useCallback } from 'react';
import { MessageChannelSyncStage } from 'twenty-shared/types';
import {
MessageChannelSyncStage,
MessageChannelType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -33,7 +36,8 @@ export const SettingsAccountsMessageChannelsContainer = () => {
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) => ({
@@ -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 (
<SubMenuTopBarContainer
title={t`New Email Group`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`General`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`New Email Group` },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={loading}
isLoading={loading}
onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Email Address`}
description={t`Enter the email address you want to forward emails from (e.g. support@mycompany.com).`}
/>
<SettingsTextInput
instanceId="email-group-handle"
label={t`Source Email Address`}
placeholder="support@mycompany.com"
value={handle}
onChange={setHandle}
disabled={loading}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -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<typeof SettingsAccountsMessageChannelDetails> = {
args: {
messageChannel: {
id: '20202020-ef5a-4822-9e08-ce6e6a4dcb6a',
type: MessageChannelType.EMAIL,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.SENT,
excludeNonProfessionalEmails: true,
excludeGroupEmails: false,
@@ -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
}
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_EMAIL_GROUP_CHANNEL = gql`
mutation DeleteEmailGroupChannel($id: UUID!) {
deleteEmailGroupChannel(id: $id) {
id
}
}
`;
@@ -17,6 +17,10 @@ export const GET_MY_MESSAGE_CHANNELS = gql`
syncStage
syncStageStartedAt
connectedAccountId
connectedAccount {
id
handle
}
createdAt
updatedAt
}
@@ -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 };
};
@@ -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 };
};
@@ -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,
@@ -9,7 +9,7 @@ import {
} from 'twenty-shared/types';
export const computeSyncStatus = (
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage'>,
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage' | 'type'>,
calendarChannel?: Pick<CalendarChannel, 'syncStatus' | 'syncStage'>,
): SyncStatus => {
const {
@@ -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 (
<Section>
<H2Title
title={t`Email Groups`}
description={t`Workspace-level shared addresses that receive forwarded mail.`}
/>
{emailGroupChannels.length > 0 && (
<Table>
<TableRow gridAutoColumns={GRID_AUTO_COLUMNS}>
<TableHeader
padding={`0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[2]}`}
>
<Trans>Source</Trans>
</TableHeader>
<TableHeader
padding={`0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[2]}`}
>
<Trans>Forwarding address</Trans>
</TableHeader>
</TableRow>
<StyledTableRows>
{emailGroupChannels.map((channel) => {
const sourceHandle =
channel.connectedAccount?.handle ?? channel.handle;
return (
<StyledClickableRow key={channel.id}>
<TableRow
gridAutoColumns={GRID_AUTO_COLUMNS}
onClick={() =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
})
}
>
<TableCell>
<StyledNameCell>
<IconMail size={16} />
<StyledHandle>{sourceHandle}</StyledHandle>
</StyledNameCell>
</TableCell>
<TableCell>
<StyledForwardingCell>
{channel.handle}
</StyledForwardingCell>
</TableCell>
</TableRow>
</StyledClickableRow>
);
})}
</StyledTableRows>
</Table>
)}
<StyledFooter>
<Button
Icon={IconPlus}
title={t`Add email group`}
variant="secondary"
size="small"
onClick={() => navigateSettings(SettingsPath.NewEmailGroupChannel)}
/>
</StyledFooter>
</Section>
);
};
@@ -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,
@@ -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 (
<SubMenuTopBarContainer
title={t`General`}
@@ -33,6 +45,8 @@ export const SettingsWorkspace = () => {
<NameField />
</Section>
{showEmailGroupSection && <SettingsWorkspaceEmailGroupSection />}
<Section>
<DeleteWorkspace />
</Section>
@@ -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 <Navigate to={getSettingsPath(SettingsPath.Workspace)} replace />;
}
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 (
<SubMenuTopBarContainer
title={sourceHandle}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`General`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: sourceHandle },
]}
actionButton={
<Button
Icon={IconTrash}
title={t`Delete`}
variant="secondary"
accent="danger"
size="small"
disabled={deleting}
onClick={() => openModal(DELETE_EMAIL_GROUP_MODAL_ID)}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Source address`}
description={t`The external address whose mail is forwarded into the workspace.`}
/>
<SettingsTextInput
instanceId="email-group-source"
value={sourceHandle}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Forwarding address`}
description={t`Set up forwarding from the source address to this destination.`}
/>
<StyledForwardingRow>
<StyledForwardingInputContainer>
<SettingsTextInput
instanceId="email-group-forwarding"
value={forwardingAddress}
disabled
fullWidth
/>
</StyledForwardingInputContainer>
<Button
Icon={IconCopy}
title={t`Copy`}
onClick={() =>
copyToClipboard(
forwardingAddress,
t`Forwarding address copied to clipboard`,
)
}
/>
</StyledForwardingRow>
</Section>
<SettingsAccountsMessageChannelDetails messageChannel={channel} />
</SettingsPageContainer>
<ConfirmationModal
modalInstanceId={DELETE_EMAIL_GROUP_MODAL_ID}
title={t`Delete email group`}
subtitle={t`Are you sure you want to delete ${sourceHandle}? Forwarded emails will no longer arrive in this workspace.`}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
loading={deleting}
/>
</SubMenuTopBarContainer>
);
};
@@ -53,6 +53,7 @@ export const mockedClientConfig: ClientConfig = {
isAttachmentPreviewEnabled: true,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
isTwoFactorAuthenticationEnabled: false,
isEmailingDomainsEnabled: false,
allowRequestsToTwentyIcons: true,
+1
View File
@@ -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",
@@ -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<void> {
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<void> {
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"',
);
}
}
@@ -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,
];
@@ -1140,6 +1140,7 @@ export class AuthService {
return [];
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return [];
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.APP:
return [];
default:
@@ -94,6 +94,7 @@ describe('ClientConfigController', () => {
isGoogleCalendarEnabled: false,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
calendarBookingPageId: undefined,
isTwoFactorAuthenticationEnabled: false,
allowRequestsToTwentyIcons: true,
@@ -309,6 +309,9 @@ export class ClientConfig {
@Field(() => Boolean)
isImapSmtpCaldavEnabled: boolean;
@Field(() => Boolean)
isEmailGroupEnabled: boolean;
@Field(() => Boolean)
allowRequestsToTwentyIcons: boolean;
@@ -171,6 +171,7 @@ describe('ClientConfigService', () => {
isGoogleCalendarEnabled: true,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isEmailGroupEnabled: false,
allowRequestsToTwentyIcons: false,
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
@@ -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',
),
@@ -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,
@@ -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<Request>,
): Promise<void> {
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');
}
}
}
@@ -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 {}
@@ -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<void> {
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<void> {
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<MessagingInboundEmailImportJobData>(
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;
}
}
}
@@ -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<void> {
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);
}
}
@@ -0,0 +1,5 @@
import { type SESMessage } from 'aws-lambda';
export type SesInboundNotification = SESMessage & {
notificationType?: string;
};
@@ -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@<domain>). 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',
@@ -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`] = `
{
@@ -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;
}
@@ -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;
}
@@ -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<MessageChannelEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
@@ -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<CreateEmailGroupChannelOutput> {
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,
@@ -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);
}
@@ -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<MessageFolderEntity>,
private readonly messagingProcessGroupEmailActionsService: MessagingProcessGroupEmailActionsService,
) {}
@ResolveField('connectedAccount', () => ConnectedAccountPublicDTO, {
nullable: true,
})
async connectedAccount(
@Parent() messageChannel: MessageChannelDTO,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedAccountPublicDTO | null> {
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<CreateEmailGroupChannelOutput> {
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<MessageChannelDTO> {
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;
}
}
@@ -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);
}
@@ -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,
@@ -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,
@@ -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,
+1
View File
@@ -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(
@@ -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,
},
});
@@ -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;
@@ -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,
@@ -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),
},
},
);
@@ -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),
},
},
);
@@ -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),
},
})
@@ -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<ConnectedAccountEntity, 'handle' | 'handleAliases'>,
): 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',
}));
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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<MessageChannelEntity>,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
) {}
async importInboundMessage(
params: ImportInboundMessageParams,
): Promise<InboundEmailImportOutcome> {
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
);
}
}
@@ -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<ParsedInboundMessage> {
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),
};
}
}
@@ -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<Buffer> {
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<void> {
const client = this.inboundEmailS3ClientProvider.getClient();
const bucket = this.inboundEmailS3ClientProvider.getBucket();
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: s3Key }));
}
}
@@ -0,0 +1,4 @@
export type InboundEmailImportOutcome =
| { kind: 'imported'; workspaceId: string; messageChannelId: string }
| { kind: 'unmatched'; recipient: string | null }
| { kind: 'unconfigured' };
@@ -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;
};
@@ -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<void> {
const { s3Key, envelopeRecipients } = data;
const outcome = await this.inboundEmailImportService.importInboundMessage({
s3Key,
envelopeRecipients,
});
this.logger.log(
`Inbound email import outcome for ${s3Key}: ${outcome.kind}`,
);
}
}
@@ -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,
],
})
@@ -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 || ''),
}));
};
@@ -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,
),
);
};
@@ -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()}`;
};
@@ -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:
@@ -4,5 +4,6 @@ export enum ConnectedAccountProvider {
IMAP_SMTP_CALDAV = 'imap_smtp_caldav',
OIDC = 'oidc',
SAML = 'saml',
EMAIL_GROUP = 'email_group',
APP = 'app',
}
@@ -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',
@@ -1,4 +1,5 @@
export enum MessageChannelType {
EMAIL = 'EMAIL',
SMS = 'SMS',
EMAIL_GROUP = 'EMAIL_GROUP',
}
@@ -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',
+10
View File
@@ -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"