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:
@@ -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;
|
||||
|
||||
+4
-3
@@ -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>
|
||||
)}
|
||||
|
||||
+41
-25
@@ -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
|
||||
|
||||
+6
-2
@@ -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) => ({
|
||||
|
||||
+89
@@ -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
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_EMAIL_GROUP_CHANNEL = gql`
|
||||
mutation DeleteEmailGroupChannel($id: UUID!) {
|
||||
deleteEmailGroupChannel(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
+4
@@ -17,6 +17,10 @@ export const GET_MY_MESSAGE_CHANNELS = gql`
|
||||
syncStage
|
||||
syncStageStartedAt
|
||||
connectedAccountId
|
||||
connectedAccount {
|
||||
id
|
||||
handle
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
+49
@@ -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 };
|
||||
};
|
||||
+31
@@ -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 };
|
||||
};
|
||||
+14
@@ -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 {
|
||||
|
||||
+136
@@ -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>
|
||||
|
||||
+151
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+35
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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:
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+1
@@ -171,6 +171,7 @@ describe('ClientConfigService', () => {
|
||||
isGoogleCalendarEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isEmailGroupEnabled: false,
|
||||
allowRequestsToTwentyIcons: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+67
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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 {}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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
-1
@@ -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`] = `
|
||||
{
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+72
@@ -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,
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+78
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-1
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
-2
@@ -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),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+6
-2
@@ -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),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+3
-1
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
+10
-68
@@ -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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_PREFIX = 'ch_';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES = 6;
|
||||
+29
@@ -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 {}
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+127
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+43
@@ -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 }));
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type InboundEmailImportOutcome =
|
||||
| { kind: 'imported'; workspaceId: string; messageChannelId: string }
|
||||
| { kind: 'unmatched'; recipient: string | null }
|
||||
| { kind: 'unconfigured' };
|
||||
+8
@@ -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;
|
||||
};
|
||||
+37
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+25
@@ -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 || ''),
|
||||
}));
|
||||
};
|
||||
+21
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
+35
@@ -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()}`;
|
||||
};
|
||||
+8
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user