Outbound message domains (#14557)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
neo773 2025-09-24 14:03:04 +05:30 committed by GitHub
parent 6ea7aea92b
commit a1ad8e3a1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3053 additions and 103 deletions

View file

@ -488,6 +488,7 @@ export enum ConfigVariableType {
export enum ConfigVariablesGroup {
AnalyticsConfig = 'AnalyticsConfig',
AwsSesSettings = 'AwsSesSettings',
BillingConfig = 'BillingConfig',
CaptchaConfig = 'CaptchaConfig',
CloudflareConfig = 'CloudflareConfig',
@ -1034,6 +1035,29 @@ export type EmailPasswordResetLink = {
success: Scalars['Boolean'];
};
export type EmailingDomain = {
__typename?: 'EmailingDomain';
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
driver: EmailingDomainDriver;
id: Scalars['UUID'];
status: EmailingDomainStatus;
updatedAt: Scalars['DateTime'];
verificationRecords?: Maybe<Array<VerificationRecord>>;
verifiedAt?: Maybe<Scalars['DateTime']>;
};
export enum EmailingDomainDriver {
AWS_SES = 'AWS_SES'
}
export enum EmailingDomainStatus {
FAILED = 'FAILED',
PENDING = 'PENDING',
TEMPORARY_FAILURE = 'TEMPORARY_FAILURE',
VERIFIED = 'VERIFIED'
}
export type ExecuteServerlessFunctionInput = {
/** Id of the serverless function to execute */
id: Scalars['UUID'];
@ -1065,6 +1089,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@ -1476,6 +1501,7 @@ export type Mutation = {
createCoreViewSort: CoreViewSort;
createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion;
createEmailingDomain: EmailingDomain;
createFile: File;
createOIDCIdentityProvider: SetupSsoOutput;
createObjectEvent: Analytics;
@ -1504,6 +1530,7 @@ export type Mutation = {
deleteCoreViewSort: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteEmailingDomain: Scalars['Boolean'];
deleteFile: File;
deleteOneAgent: Agent;
deleteOneField: Field;
@ -1613,6 +1640,7 @@ export type Mutation = {
upsertPermissionFlags: Array<PermissionFlag>;
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
verifyEmailingDomain: EmailingDomain;
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput;
};
@ -1725,6 +1753,12 @@ export type MutationCreateDraftFromWorkflowVersionArgs = {
};
export type MutationCreateEmailingDomainArgs = {
domain: Scalars['String'];
driver: EmailingDomainDriver;
};
export type MutationCreateFileArgs = {
file: Scalars['Upload'];
};
@ -1863,6 +1897,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
};
export type MutationDeleteEmailingDomainArgs = {
id: Scalars['String'];
};
export type MutationDeleteFileArgs = {
fileId: Scalars['UUID'];
};
@ -2409,6 +2448,11 @@ export type MutationValidateApprovedAccessDomainArgs = {
};
export type MutationVerifyEmailingDomainArgs = {
id: Scalars['String'];
};
export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = {
otp: Scalars['String'];
};
@ -2742,6 +2786,7 @@ export type Query = {
getCoreViewSorts: Array<CoreViewSort>;
getCoreViews: Array<CoreView>;
getDatabaseConfigVariable: ConfigVariable;
getEmailingDomains: Array<EmailingDomain>;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
getPageLayout?: Maybe<PageLayout>;
@ -3899,6 +3944,14 @@ export type ValidatePasswordResetToken = {
id: Scalars['UUID'];
};
export type VerificationRecord = {
__typename?: 'VerificationRecord';
key: Scalars['String'];
priority?: Maybe<Scalars['Float']>;
type: Scalars['String'];
value: Scalars['String'];
};
export type VerifyTwoFactorAuthenticationMethodOutput = {
__typename?: 'VerifyTwoFactorAuthenticationMethodOutput';
success: Scalars['Boolean'];
@ -4916,6 +4969,33 @@ export type FindManyPublicDomainsQueryVariables = Exact<{ [key: string]: never;
export type FindManyPublicDomainsQuery = { __typename?: 'Query', findManyPublicDomains: Array<{ __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string }> };
export type CreateEmailingDomainMutationVariables = Exact<{
domain: Scalars['String'];
driver: EmailingDomainDriver;
}>;
export type CreateEmailingDomainMutation = { __typename?: 'Mutation', createEmailingDomain: { __typename?: 'EmailingDomain', id: string, domain: string, driver: EmailingDomainDriver, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null }> | null } };
export type DeleteEmailingDomainMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type DeleteEmailingDomainMutation = { __typename?: 'Mutation', deleteEmailingDomain: boolean };
export type VerifyEmailingDomainMutationVariables = Exact<{
id: Scalars['String'];
}>;
export type VerifyEmailingDomainMutation = { __typename?: 'Mutation', verifyEmailingDomain: { __typename?: 'EmailingDomain', id: string, domain: string, driver: EmailingDomainDriver, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string } };
export type GetEmailingDomainsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetEmailingDomainsQuery = { __typename?: 'Query', getEmailingDomains: Array<{ __typename?: 'EmailingDomain', id: string, domain: string, driver: EmailingDomainDriver, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null }> | null }> };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
}>;
@ -10015,6 +10095,168 @@ export function useFindManyPublicDomainsLazyQuery(baseOptions?: Apollo.LazyQuery
export type FindManyPublicDomainsQueryHookResult = ReturnType<typeof useFindManyPublicDomainsQuery>;
export type FindManyPublicDomainsLazyQueryHookResult = ReturnType<typeof useFindManyPublicDomainsLazyQuery>;
export type FindManyPublicDomainsQueryResult = Apollo.QueryResult<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>;
export const CreateEmailingDomainDocument = gql`
mutation CreateEmailingDomain($domain: String!, $driver: EmailingDomainDriver!) {
createEmailingDomain(domain: $domain, driver: $driver) {
id
domain
driver
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;
export type CreateEmailingDomainMutationFn = Apollo.MutationFunction<CreateEmailingDomainMutation, CreateEmailingDomainMutationVariables>;
/**
* __useCreateEmailingDomainMutation__
*
* To run a mutation, you first call `useCreateEmailingDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateEmailingDomainMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createEmailingDomainMutation, { data, loading, error }] = useCreateEmailingDomainMutation({
* variables: {
* domain: // value for 'domain'
* driver: // value for 'driver'
* },
* });
*/
export function useCreateEmailingDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreateEmailingDomainMutation, CreateEmailingDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateEmailingDomainMutation, CreateEmailingDomainMutationVariables>(CreateEmailingDomainDocument, options);
}
export type CreateEmailingDomainMutationHookResult = ReturnType<typeof useCreateEmailingDomainMutation>;
export type CreateEmailingDomainMutationResult = Apollo.MutationResult<CreateEmailingDomainMutation>;
export type CreateEmailingDomainMutationOptions = Apollo.BaseMutationOptions<CreateEmailingDomainMutation, CreateEmailingDomainMutationVariables>;
export const DeleteEmailingDomainDocument = gql`
mutation DeleteEmailingDomain($id: String!) {
deleteEmailingDomain(id: $id)
}
`;
export type DeleteEmailingDomainMutationFn = Apollo.MutationFunction<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>;
/**
* __useDeleteEmailingDomainMutation__
*
* To run a mutation, you first call `useDeleteEmailingDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteEmailingDomainMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteEmailingDomainMutation, { data, loading, error }] = useDeleteEmailingDomainMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useDeleteEmailingDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>(DeleteEmailingDomainDocument, options);
}
export type DeleteEmailingDomainMutationHookResult = ReturnType<typeof useDeleteEmailingDomainMutation>;
export type DeleteEmailingDomainMutationResult = Apollo.MutationResult<DeleteEmailingDomainMutation>;
export type DeleteEmailingDomainMutationOptions = Apollo.BaseMutationOptions<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>;
export const VerifyEmailingDomainDocument = gql`
mutation VerifyEmailingDomain($id: String!) {
verifyEmailingDomain(id: $id) {
id
domain
driver
status
verifiedAt
createdAt
updatedAt
}
}
`;
export type VerifyEmailingDomainMutationFn = Apollo.MutationFunction<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;
/**
* __useVerifyEmailingDomainMutation__
*
* To run a mutation, you first call `useVerifyEmailingDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useVerifyEmailingDomainMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [verifyEmailingDomainMutation, { data, loading, error }] = useVerifyEmailingDomainMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useVerifyEmailingDomainMutation(baseOptions?: Apollo.MutationHookOptions<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>(VerifyEmailingDomainDocument, options);
}
export type VerifyEmailingDomainMutationHookResult = ReturnType<typeof useVerifyEmailingDomainMutation>;
export type VerifyEmailingDomainMutationResult = Apollo.MutationResult<VerifyEmailingDomainMutation>;
export type VerifyEmailingDomainMutationOptions = Apollo.BaseMutationOptions<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;
export const GetEmailingDomainsDocument = gql`
query GetEmailingDomains {
getEmailingDomains {
id
domain
driver
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;
/**
* __useGetEmailingDomainsQuery__
*
* To run a query within a React component, call `useGetEmailingDomainsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetEmailingDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetEmailingDomainsQuery({
* variables: {
* },
* });
*/
export function useGetEmailingDomainsQuery(baseOptions?: Apollo.QueryHookOptions<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>(GetEmailingDomainsDocument, options);
}
export function useGetEmailingDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>(GetEmailingDomainsDocument, options);
}
export type GetEmailingDomainsQueryHookResult = ReturnType<typeof useGetEmailingDomainsQuery>;
export type GetEmailingDomainsLazyQueryHookResult = ReturnType<typeof useGetEmailingDomainsLazyQuery>;
export type GetEmailingDomainsQueryResult = Apollo.QueryResult<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>;
export const UpdateLabPublicFeatureFlagDocument = gql`
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
updateLabPublicFeatureFlag(input: $input) {

View file

@ -488,6 +488,7 @@ export enum ConfigVariableType {
export enum ConfigVariablesGroup {
AnalyticsConfig = 'AnalyticsConfig',
AwsSesSettings = 'AwsSesSettings',
BillingConfig = 'BillingConfig',
CaptchaConfig = 'CaptchaConfig',
CloudflareConfig = 'CloudflareConfig',
@ -998,6 +999,29 @@ export type EmailPasswordResetLink = {
success: Scalars['Boolean'];
};
export type EmailingDomain = {
__typename?: 'EmailingDomain';
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
driver: EmailingDomainDriver;
id: Scalars['UUID'];
status: EmailingDomainStatus;
updatedAt: Scalars['DateTime'];
verificationRecords?: Maybe<Array<VerificationRecord>>;
verifiedAt?: Maybe<Scalars['DateTime']>;
};
export enum EmailingDomainDriver {
AWS_SES = 'AWS_SES'
}
export enum EmailingDomainStatus {
FAILED = 'FAILED',
PENDING = 'PENDING',
TEMPORARY_FAILURE = 'TEMPORARY_FAILURE',
VERIFIED = 'VERIFIED'
}
export type ExecuteServerlessFunctionInput = {
/** Id of the serverless function to execute */
id: Scalars['UUID'];
@ -1029,6 +1053,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@ -1433,6 +1458,7 @@ export type Mutation = {
createCoreViewSort: CoreViewSort;
createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion;
createEmailingDomain: EmailingDomain;
createFile: File;
createOIDCIdentityProvider: SetupSsoOutput;
createObjectEvent: Analytics;
@ -1460,6 +1486,7 @@ export type Mutation = {
deleteCoreViewSort: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteEmailingDomain: Scalars['Boolean'];
deleteFile: File;
deleteOneAgent: Agent;
deleteOneField: Field;
@ -1564,6 +1591,7 @@ export type Mutation = {
upsertPermissionFlags: Array<PermissionFlag>;
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
verifyEmailingDomain: EmailingDomain;
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput;
};
@ -1676,6 +1704,12 @@ export type MutationCreateDraftFromWorkflowVersionArgs = {
};
export type MutationCreateEmailingDomainArgs = {
domain: Scalars['String'];
driver: EmailingDomainDriver;
};
export type MutationCreateFileArgs = {
file: Scalars['Upload'];
};
@ -1799,6 +1833,11 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
};
export type MutationDeleteEmailingDomainArgs = {
id: Scalars['String'];
};
export type MutationDeleteFileArgs = {
fileId: Scalars['UUID'];
};
@ -2320,6 +2359,11 @@ export type MutationValidateApprovedAccessDomainArgs = {
};
export type MutationVerifyEmailingDomainArgs = {
id: Scalars['String'];
};
export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = {
otp: Scalars['String'];
};
@ -2650,6 +2694,7 @@ export type Query = {
getCoreViewSorts: Array<CoreViewSort>;
getCoreViews: Array<CoreView>;
getDatabaseConfigVariable: ConfigVariable;
getEmailingDomains: Array<EmailingDomain>;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
getPageLayout?: Maybe<PageLayout>;
@ -3727,6 +3772,14 @@ export type ValidatePasswordResetToken = {
id: Scalars['UUID'];
};
export type VerificationRecord = {
__typename?: 'VerificationRecord';
key: Scalars['String'];
priority?: Maybe<Scalars['Float']>;
type: Scalars['String'];
value: Scalars['String'];
};
export type VerifyTwoFactorAuthenticationMethodOutput = {
__typename?: 'VerifyTwoFactorAuthenticationMethodOutput';
success: Scalars['Boolean'];

View file

@ -3,9 +3,9 @@ import { Route, Routes } from 'react-router-dom';
import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsProtectedRouteWrapper';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingPublicDomain } from '@/settings/domains/components/SettingPublicDomain';
import { SettingsPath } from 'twenty-shared/types';
import { PermissionFlagType } from '~/generated/graphql';
import { SettingPublicDomain } from '@/settings/domains/components/SettingPublicDomain';
const SettingsGraphQLPlayground = lazy(() =>
import(
@ -310,6 +310,22 @@ const SettingsSecurityApprovedAccessDomain = lazy(() =>
),
);
const SettingsNewEmailingDomain = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsNewEmailingDomain').then(
(module) => ({
default: module.SettingsNewEmailingDomain,
}),
),
);
const SettingsEmailingDomainDetail = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsEmailingDomainDetail').then(
(module) => ({
default: module.SettingsEmailingDomainDetail,
}),
),
);
const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
@ -429,6 +445,14 @@ export const SettingsRoutes = ({
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
/>
<Route
path={SettingsPath.EmailingDomainDetail}
element={<SettingsEmailingDomainDetail />}
/>
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}

View file

@ -11,6 +11,7 @@ import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabl
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
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';
@ -105,6 +106,9 @@ export const useClientConfig = (): UseClientConfigResult => {
const setIsImapSmtpCaldavEnabled = useSetRecoilState(
isImapSmtpCaldavEnabledState,
);
const setIsEmailingDomainsEnabled = useSetRecoilState(
isEmailingDomainsEnabledState,
);
const setAppVersion = useSetRecoilState(appVersionState);
@ -179,6 +183,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
} catch (err) {
const error =
err instanceof Error ? err : new Error('Failed to fetch client config');
@ -211,6 +216,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsEmailVerificationRequired,
setIsImapSmtpCaldavEnabled,
setIsMultiWorkspaceEnabled,
setIsEmailingDomainsEnabled,
setLabPublicFeatureFlags,
setMicrosoftCalendarEnabled,
setMicrosoftMessagingEnabled,

View file

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const isEmailingDomainsEnabledState = createState<boolean>({
key: 'isEmailingDomainsEnabled',
defaultValue: false,
});

View file

@ -32,6 +32,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailingDomainsEnabled: boolean;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;

View file

@ -0,0 +1,122 @@
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 styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip, Status } from 'twenty-ui/display';
import { type ThemeColor } from 'twenty-ui/theme';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type RecordStatus = {
status: string;
statusColor: ThemeColor;
};
type DnsRecordBase = {
type: string;
key: string;
value: string;
priority?: number | null;
ttl?: string;
};
type DnsRecord = DnsRecordBase | (DnsRecordBase & RecordStatus);
type SettingsDnsRecordsTableProps = {
records: DnsRecord[];
};
const StyledTableRow = styled(TableRow)`
& > * {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
`;
const StyledTableCell = styled(TableCell)`
font-family: monospace;
`;
export const SettingsDnsRecordsTable = ({
records,
}: SettingsDnsRecordsTableProps) => {
const { copyToClipboard } = useCopyToClipboard();
if (!records || records.length === 0) {
return null;
}
const hasTtlRecords = records.some((record) => isDefined(record.ttl));
const hasStatusRecords = records.some((record) => 'status' in record);
const hasPriorityRecords = records.some((record) =>
isDefined(record.priority),
);
const buildGridColumns = () => {
const baseColumns = ['max-content', '1fr', '1fr'];
if (hasPriorityRecords) baseColumns.push('max-content');
if (hasTtlRecords) baseColumns.push('max-content');
if (hasStatusRecords) baseColumns.push('max-content');
return baseColumns.join(' ');
};
const gridAutoColumns = buildGridColumns();
return (
<Table>
<StyledTableRow gridAutoColumns={gridAutoColumns}>
<TableHeader align="center">{t`Type`}</TableHeader>
<TableHeader align="center">{t`Key`}</TableHeader>
<TableHeader align="center">{t`Value`}</TableHeader>
{hasPriorityRecords && (
<TableHeader align="center">{t`Priority`}</TableHeader>
)}
{hasTtlRecords && <TableHeader align="center">{t`TTL`}</TableHeader>}
{hasStatusRecords && (
<TableHeader align="center">{t`Status`}</TableHeader>
)}
</StyledTableRow>
{records.map((record) => (
<StyledTableRow key={record.value} gridAutoColumns={gridAutoColumns}>
<TableCell>{record.type}</TableCell>
<StyledTableCell
onClick={() => {
copyToClipboard(record.key || '');
}}
>
<OverflowingTextWithTooltip text={record.key} />
</StyledTableCell>
<StyledTableCell
onClick={() => {
copyToClipboard(record.value);
}}
>
<OverflowingTextWithTooltip text={record.value} />
</StyledTableCell>
{hasPriorityRecords && (
<StyledTableCell>{record.priority}</StyledTableCell>
)}
{hasTtlRecords && <StyledTableCell>{record.ttl}</StyledTableCell>}
{hasStatusRecords && (
<StyledTableCell>
{'status' in record ? (
<Status
color={record.statusColor}
text={capitalize(record.status)}
/>
) : null}
</StyledTableCell>
)}
</StyledTableRow>
))}
</Table>
);
};

View file

@ -1,84 +1,39 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
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 styled from '@emotion/styled';
import { Button } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import {
type DomainRecord,
type DomainValidRecords,
} from '~/generated/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { capitalize } from 'twenty-shared/utils';
import { useRecoilValue } from 'recoil';
import { type ThemeColor } from 'twenty-ui/theme';
import { Status } from 'twenty-ui/display';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
const StyledTable = styled(Table)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableCell = styled(TableCell)`
overflow: hidden;
display: block;
padding: 0 ${({ theme }) => theme.spacing(3)} 0 0;
&:first-of-type {
padding-left: 0;
}
&:last-of-type {
padding-right: 0;
}
`;
const StyledButton = styled(Button)`
border: 1px solid ${({ theme }) => theme.border.color.medium};
color: ${({ theme }) => theme.font.color.tertiary};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(6)};
overflow: hidden;
user-select: text;
width: 100%;
`;
export const SettingsDomainRecords = ({
records,
}: {
records: DomainValidRecords['records'];
}) => {
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { copyToClipboard } = useCopyToClipboard();
const copyToClipboardDebounced = useDebouncedCallback(
(value: string) => copyToClipboard(value),
200,
);
const rowsDefinitions = [
{ name: 'Domain Setup', validationType: 'redirection' as const },
{ name: 'Secure Connection', validationType: 'ssl' as const },
];
const defaultValues: { status: string; color: ThemeColor } =
currentWorkspace?.customDomain === customDomainRecords?.domain
const defaultValues: { status: string; statusColor: ThemeColor } =
currentWorkspace?.customDomain
? {
status: 'success',
color: 'green',
statusColor: 'green',
}
: {
status: 'loading',
color: 'gray',
statusColor: 'gray',
};
const rows = rowsDefinitions.map<
{ name: string; status: string; color: ThemeColor } & DomainRecord
const transformedRecords = rowsDefinitions.map<
{ statusColor: ThemeColor } & DomainRecord
>((row) => {
const record = records.find(
({ validationType }) => validationType === row.validationType,
@ -89,55 +44,23 @@ export const SettingsDomainRecords = ({
}
return {
name: row.name,
color:
statusColor:
record && record.status === 'error'
? 'red'
: record && record.status === 'pending'
? 'yellow'
: defaultValues.color,
: defaultValues.statusColor,
...record,
};
});
return (
<StyledTable>
<TableRow gridAutoColumns="30% 16% 38% 16%">
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{rows.map((row) => (
<TableRow gridAutoColumns="30% 16% 38% 16%" key={row.name}>
<StyledTableCell>
<StyledButton
title={row.key}
onClick={() => copyToClipboardDebounced(row.key)}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={row.type.toUpperCase()}
onClick={() => copyToClipboardDebounced(row.type.toUpperCase())}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={row.value}
onClick={() => copyToClipboardDebounced(row.value)}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<Status color={row.color} text={capitalize(row.status)} />
</StyledTableCell>
</TableRow>
))}
</TableBody>
</StyledTable>
<Section>
<H2Title
title="Domain Setup"
description="Configure these DNS records with your domain provider"
/>
<SettingsDnsRecordsTable records={transformedRecords} />
</Section>
);
};

View file

@ -0,0 +1,78 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import {
type GetEmailingDomainsQuery,
useDeleteEmailingDomainMutation,
useGetEmailingDomainsQuery,
} from '~/generated-metadata/graphql';
type SettingsEmailingDomainRowDropdownMenuProps = {
emailingDomain: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainRowDropdownMenu = ({
emailingDomain,
}: SettingsEmailingDomainRowDropdownMenuProps) => {
const dropdownId = `settings-emailing-domain-row-${emailingDomain.id}`;
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { closeDropdown } = useCloseDropdown();
const { refetch: refetchEmailingDomains } = useGetEmailingDomainsQuery();
const [deleteEmailingDomainMutation] = useDeleteEmailingDomainMutation();
const handleDeleteEmailingDomain = async () => {
try {
await deleteEmailingDomainMutation({
variables: {
id: emailingDomain.id,
},
});
enqueueSuccessSnackBar({
message: t`Emailing domain deleted successfully`,
});
await refetchEmailingDomains();
} catch (error) {
enqueueErrorSnackBar({
...(error instanceof ApolloError ? { apolloError: error } : {}),
});
}
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text={t`Delete`}
onClick={() => {
handleDeleteEmailingDomain();
closeDropdown(dropdownId);
}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};

View file

@ -0,0 +1,68 @@
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { H2Title, IconRefresh } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
type EmailingDomain,
useVerifyEmailingDomainMutation,
} from '~/generated-metadata/graphql';
type SettingsEmailingDomainVerificationRecordsProps = {
domain: EmailingDomain;
};
export const SettingsEmailingDomainVerificationRecords = ({
domain,
}: SettingsEmailingDomainVerificationRecordsProps) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomainMutation, { loading: isVerifying }] =
useVerifyEmailingDomainMutation();
if (!domain.verificationRecords || domain.verificationRecords.length === 0) {
return null;
}
const handleVerifyEmailingDomain = async () => {
try {
await verifyEmailingDomainMutation({
variables: {
id: domain.id,
},
});
enqueueSuccessSnackBar({
message: t`Started verification process`,
});
} catch (error) {
enqueueErrorSnackBar({
...(error instanceof ApolloError ? { apolloError: error } : {}),
});
}
};
return (
<Section>
<H2Title
title={t`DNS Records`}
description={t`Add these records to verify your domain.`}
adornment={
<Button
onClick={handleVerifyEmailingDomain}
isLoading={isVerifying}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={isVerifying}
/>
}
/>
<SettingsDnsRecordsTable records={domain.verificationRecords} />
</Section>
);
};

View file

@ -0,0 +1,24 @@
import { gql } from '@apollo/client';
export const CREATE_EMAILING_DOMAIN = gql`
mutation CreateEmailingDomain(
$domain: String!
$driver: EmailingDomainDriver!
) {
createEmailingDomain(domain: $domain, driver: $driver) {
id
domain
driver
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;

View file

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_EMAILING_DOMAIN = gql`
mutation DeleteEmailingDomain($id: String!) {
deleteEmailingDomain(id: $id)
}
`;

View file

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const VERIFY_EMAILING_DOMAIN = gql`
mutation VerifyEmailingDomain($id: String!) {
verifyEmailingDomain(id: $id) {
id
domain
driver
status
verifiedAt
createdAt
updatedAt
}
}
`;

View file

@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const GET_ALL_EMAILING_DOMAINS = gql`
query GetEmailingDomains {
getEmailingDomains {
id
domain
driver
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;

View file

@ -2,16 +2,18 @@ import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { SettingsWorkspaceDomainCard } from '@/settings/domains/components/SettingsWorkspaceDomainCard';
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
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 { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { FeatureFlagKey } from '~/generated/graphql';
import { SettingsEmailingDomains } from '~/pages/settings/emailing-domains/SettingsEmailingDomains';
const StyledMainContent = styled.div`
display: flex;
@ -31,6 +33,10 @@ export const SettingsDomains = () => {
const isPublicDomainEnabled =
featureFlags[FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED];
const isEmailingDomainsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
);
return (
<SubMenuTopBarContainer
title={t`Domains`}
@ -58,6 +64,15 @@ export const SettingsDomains = () => {
/>
<SettingsApprovedAccessDomainsListCard />
</StyledSection>
{isEmailingDomainsEnabled && (
<StyledSection>
<H2Title
title={t`Emailing Domains`}
description={t`Configure and verify domains for emailing from this workspace.`}
/>
<SettingsEmailingDomains />
</StyledSection>
)}
{isPublicDomainEnabled && (
<StyledSection>
<H2Title

View file

@ -0,0 +1,60 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsEmailingDomainVerificationRecords } from '@/settings/emailing-domains/components/SettingsEmailingDomainVerificationRecords';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useQuery } from '@apollo/client';
import { Trans } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
export const SettingsEmailingDomainDetail = () => {
const { domainId } = useParams<{ domainId: string }>();
const { data, loading, error } = useQuery<GetEmailingDomainsQuery>(
GET_ALL_EMAILING_DOMAINS,
{
skip: !domainId,
},
);
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.id === domainId,
);
if (loading) {
return <div>Loading...</div>;
}
if (isDefined(error) || !isDefined(emailingDomain)) {
return <Trans>Domain not found</Trans>;
}
return (
<SubMenuTopBarContainer
title={emailingDomain.domain}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Emailing Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: emailingDomain.domain },
]}
>
<SettingsPageContainer>
{emailingDomain.verificationRecords &&
emailingDomain.verificationRecords.length > 0 && (
<SettingsEmailingDomainVerificationRecords
domain={emailingDomain}
/>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View file

@ -0,0 +1,77 @@
import { Link, useNavigate } from 'react-router-dom';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsEmailingDomainRowDropdownMenu } from '@/settings/emailing-domains/components/SettingsEmailingDomainRowDropdownMenu';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconMail, Status } from 'twenty-ui/display';
import { useGetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsEmailingDomains = () => {
const { t } = useLingui();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const navigate = useNavigate();
const { data, loading: isLoading } = useGetEmailingDomainsQuery();
const emailingDomains = data?.getEmailingDomains ?? [];
const getItemDescription = (createdAt: string) => {
const beautifyPastDateRelative = beautifyPastDateRelativeToNow(
createdAt,
localeCatalog,
);
return t`Added ${beautifyPastDateRelative}`;
};
return isLoading || !emailingDomains.length ? (
<StyledLink to={getSettingsPath(SettingsPath.NewEmailingDomain)}>
<SettingsCard title={t`Add Emailing Domain`} Icon={<IconMail />} />
</StyledLink>
) : (
<>
<SettingsListCard
items={emailingDomains}
getItemLabel={({ domain }) => domain ?? ''}
getItemDescription={({ createdAt }) => getItemDescription(createdAt)}
RowIcon={IconMail}
onRowClick={(emailingDomain) => {
navigate(
getSettingsPath(SettingsPath.EmailingDomainDetail, {
domainId: emailingDomain.id,
}),
);
}}
RowRightComponent={({ item: emailingDomain }) => (
<>
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
<SettingsEmailingDomainRowDropdownMenu
emailingDomain={emailingDomain}
/>
</>
)}
hasFooter
footerButtonLabel="Add Emailing Domain"
onFooterButtonClick={() =>
navigate(getSettingsPath(SettingsPath.NewEmailingDomain))
}
/>
</>
);
};

View file

@ -0,0 +1,157 @@
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 { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
EmailingDomainDriver,
useCreateEmailingDomainMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import {
settingsEmailingDomainFormSchema,
type SettingsEmailingDomainFormValues,
} from '~/pages/settings/emailing-domains/validation-schemas/settingsEmailingDomainFormSchema';
type FieldErrors = Partial<
Record<keyof SettingsEmailingDomainFormValues, string>
>;
export const SettingsNewEmailingDomain = () => {
const navigate = useNavigateSettings();
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [formValues, setFormValues] =
useState<SettingsEmailingDomainFormValues>({
driver: EmailingDomainDriver.AWS_SES,
domain: '',
});
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [createEmailingDomain] = useCreateEmailingDomainMutation();
const validateForm = (): boolean => {
const result = settingsEmailingDomainFormSchema.safeParse(formValues);
if (!result.success) {
setFieldErrors(result.error?.flatten().fieldErrors as FieldErrors);
return false;
}
setFieldErrors({});
return true;
};
const handleFieldChange = (
field: keyof SettingsEmailingDomainFormValues,
value: string | EmailingDomainDriver,
) => {
setFormValues((prev) => ({
...prev,
[field]: value,
}));
if (isDefined(fieldErrors[field])) {
setFieldErrors((prev) => ({
...prev,
[field]: undefined,
}));
}
};
const canSave = !isSubmitting;
const handleSave = async () => {
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
await createEmailingDomain({
variables: {
domain: formValues.domain,
driver: formValues.driver,
},
onCompleted: (data) => {
enqueueSuccessSnackBar({
message: t`Emailing domain created successfully. Please verify the domain to start using it.`,
});
if (!data.createEmailingDomain?.id) return;
navigate(SettingsPath.EmailingDomainDetail, {
domainId: data.createEmailingDomain.id,
});
},
onError: (error) => {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
},
});
} catch (error) {
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
} finally {
setIsSubmitting(false);
}
};
return (
<SubMenuTopBarContainer
title={t`New Emailing Domain`}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
onSave={handleSave}
isSaveDisabled={!canSave}
/>
}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{
children: <Trans>Emailing Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>New Emailing Domain</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Domain`}
description={t`The domain name you want to use for emailing`}
/>
<SettingsTextInput
instanceId="emailing-domain"
autoFocus
autoComplete="off"
value={formValues.domain}
onChange={(value) => handleFieldChange('domain', value)}
fullWidth
placeholder="yourdomain.com"
error={fieldErrors.domain}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View file

@ -0,0 +1,18 @@
import { EmailingDomainStatus } from '~/generated/graphql';
export const getColorByEmailingDomainStatus = (
status: EmailingDomainStatus,
) => {
switch (status) {
case EmailingDomainStatus.VERIFIED:
return 'turquoise';
case EmailingDomainStatus.PENDING:
return 'orange';
case EmailingDomainStatus.TEMPORARY_FAILURE:
return 'orange';
case EmailingDomainStatus.FAILED:
return 'red';
default:
return 'gray';
}
};

View file

@ -0,0 +1,17 @@
import { t } from '@lingui/core/macro';
import { EmailingDomainStatus } from '~/generated/graphql';
export const getTextByEmailingDomainStatus = (status: EmailingDomainStatus) => {
switch (status) {
case EmailingDomainStatus.VERIFIED:
return t`Verified`;
case EmailingDomainStatus.PENDING:
return t`Pending`;
case EmailingDomainStatus.TEMPORARY_FAILURE:
return t`Temporary Failure`;
case EmailingDomainStatus.FAILED:
return t`Failed`;
default:
return t`Unknown`;
}
};

View file

@ -0,0 +1,18 @@
import { z } from 'zod';
import { EmailingDomainDriver } from '~/generated-metadata/graphql';
export const settingsEmailingDomainFormSchema = z.object({
domain: z
.string()
.min(1, 'Domain is required')
.regex(
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])\.[a-zA-Z]{2,}$/,
'Invalid domain format. Please enter a valid domain name.',
)
.max(256, 'Domain must be less than 256 characters.'),
driver: z.nativeEnum(EmailingDomainDriver),
});
export type SettingsEmailingDomainFormValues = z.infer<
typeof settingsEmailingDomainFormSchema
>;

View file

@ -56,4 +56,5 @@ export const mockedClientConfig: ClientConfig = {
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isTwoFactorAuthenticationEnabled: false,
isEmailingDomainsEnabled: false,
};

View file

@ -21,6 +21,7 @@
"@ai-sdk/xai": "^2.0.19",
"@aws-sdk/client-lambda": "3.825.0",
"@aws-sdk/client-s3": "3.825.0",
"@aws-sdk/client-sesv2": "^3.888.0",
"@aws-sdk/client-sts": "3.825.0",
"@aws-sdk/credential-providers": "3.825.0",
"@babel/preset-env": "7.26.9",

View file

@ -0,0 +1,31 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class CreateEmailingDomainEntity1758388517321
implements MigrationInterface
{
name = 'CreateEmailingDomainEntity1758388517321';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."emailingDomain_driver_enum" AS ENUM('AWS_SES')`,
);
await queryRunner.query(
`CREATE TYPE "core"."emailingDomain_status_enum" AS ENUM('PENDING', 'VERIFIED', 'FAILED', 'TEMPORARY_FAILURE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."emailingDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "driver" "core"."emailingDomain_driver_enum" NOT NULL, "status" "core"."emailingDomain_status_enum" NOT NULL DEFAULT 'PENDING', "verificationRecords" jsonb, "verifiedAt" TIMESTAMP WITH TIME ZONE, "workspaceId" uuid NOT NULL, CONSTRAINT "IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_dca7032537b5d307f8cc6d74f1d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."emailingDomain" ADD CONSTRAINT "FK_793a938bef2aae0a2129f78951f" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."emailingDomain" DROP CONSTRAINT "FK_793a938bef2aae0a2129f78951f"`,
);
await queryRunner.query(`DROP TABLE "core"."emailingDomain"`);
await queryRunner.query(`DROP TYPE "core"."emailingDomain_status_enum"`);
await queryRunner.query(`DROP TYPE "core"."emailingDomain_driver_enum"`);
}
}

View file

@ -20,6 +20,7 @@ import { captchaModuleFactory } from 'src/engine/core-modules/captcha/captcha.mo
import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@ -83,6 +84,7 @@ import { FileModule } from './file/file.module';
WorkspaceInvitationModule,
WorkspaceSSOModule,
ApprovedAccessDomainModule,
EmailingDomainModule,
PublicDomainModule,
CloudflareModule,
DnsManagerModule,

View file

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import {
SESv2Client as SESClient,
type SESv2ClientConfig as SESClientConfig,
} from '@aws-sdk/client-sesv2';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class AwsSesClientProvider {
private sesClient: SESClient | null = null;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
public getSESClient(): SESClient {
if (!this.sesClient) {
const config: SESClientConfig = {
region: this.twentyConfigService.get('AWS_SES_REGION'),
};
const accessKeyId = this.twentyConfigService.get('AWS_SES_ACCESS_KEY_ID');
const secretAccessKey = this.twentyConfigService.get(
'AWS_SES_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get(
'AWS_SES_SESSION_TOKEN',
);
if (accessKeyId && secretAccessKey && sessionToken) {
config.credentials = {
accessKeyId,
secretAccessKey,
sessionToken,
};
}
this.sesClient = new SESClient(config);
}
return this.sesClient;
}
}

View file

@ -0,0 +1,263 @@
import { Logger } from '@nestjs/common';
import {
CreateEmailIdentityCommand,
CreateTenantCommand,
CreateTenantResourceAssociationCommand,
GetEmailIdentityCommand,
PutEmailIdentityDkimAttributesCommand,
} from '@aws-sdk/client-sesv2';
import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
import {
type DomainStatusInput,
type DomainVerificationInput,
type EmailingDomainDriverInterface,
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { type AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { type AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
export class AwsSesDriver implements EmailingDomainDriverInterface {
private readonly logger = new Logger(AwsSesDriver.name);
constructor(
private readonly config: AwsSesDriverConfig,
private readonly awsSesClientProvider: AwsSesClientProvider,
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
) {}
async verifyDomain(
input: DomainVerificationInput,
): Promise<EmailingDomainVerificationResult> {
try {
this.logger.log(`Starting domain verification for: ${input.domain}`);
const tenantName = this.generateTenantName(input.workspaceId);
await this.ensureTenantExists(tenantName);
const { isVerified, verificationRecords } =
await this.createOrUpdateEmailIdentity(input.domain, tenantName);
if (isVerified) {
await this.enableDkimSigning(input.domain);
}
return {
status: isVerified
? EmailingDomainStatus.VERIFIED
: EmailingDomainStatus.PENDING,
verifiedAt: isVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
this.logger.error(`Failed to verify domain ${input.domain}: ${error}`);
this.awsSesHandleErrorService.handleAwsSesError(error, 'verifyDomain');
}
}
async getDomainStatus(
input: DomainStatusInput,
): Promise<EmailingDomainVerificationResult> {
try {
this.logger.log(`Getting domain status for: ${input.domain}`);
const sesClient = this.awsSesClientProvider.getSESClient();
const getIdentityCommand = new GetEmailIdentityCommand({
EmailIdentity: input.domain,
});
const identityResponse = await sesClient.send(getIdentityCommand);
const status = this.determineVerificationStatus(identityResponse);
const isFullyVerified = status === EmailingDomainStatus.VERIFIED;
const verificationRecords = this.buildVerificationRecords(
input.domain,
identityResponse.DkimAttributes?.Tokens || [],
);
return {
status,
verifiedAt: isFullyVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
if (error.name === 'NotFoundException') {
return {
status: EmailingDomainStatus.FAILED,
verifiedAt: null,
verificationRecords: [],
};
}
this.logger.error(
`Failed to get domain status ${input.domain}: ${error}`,
);
this.awsSesHandleErrorService.handleAwsSesError(error, 'getDomainStatus');
}
}
private generateTenantName(workspaceId: string): string {
return `twenty-workspace-${workspaceId}`;
}
private async ensureTenantExists(tenantName: string): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
await sesClient.send(new CreateTenantCommand({ TenantName: tenantName }));
this.logger.log(`Created tenant: ${tenantName}`);
} catch (error) {
if (error.name === 'AlreadyExistsException') {
this.logger.log(`Tenant already exists: ${tenantName}`);
return;
}
throw error;
}
}
private async createOrUpdateEmailIdentity(
domain: string,
tenantName: string,
): Promise<{
isVerified: boolean;
verificationRecords: VerificationRecord[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
const getIdentityCommand = new GetEmailIdentityCommand({
EmailIdentity: domain,
});
const existingIdentity = await sesClient.send(getIdentityCommand);
const isVerified = existingIdentity.VerifiedForSendingStatus === true;
const verificationRecords = this.buildVerificationRecords(
domain,
existingIdentity.DkimAttributes?.Tokens || [],
);
if (!isVerified) {
await this.associateResourceWithTenant(domain, tenantName);
}
return { isVerified, verificationRecords };
} catch (error) {
if (error.name === 'NotFoundException') {
return await this.createNewEmailIdentity(domain, tenantName);
}
throw error;
}
}
private async createNewEmailIdentity(
domain: string,
tenantName: string,
): Promise<{
isVerified: boolean;
verificationRecords: VerificationRecord[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
const createCommand = new CreateEmailIdentityCommand({
EmailIdentity: domain,
Tags: [{ Key: 'Tenant', Value: tenantName }],
});
const createResponse = await sesClient.send(createCommand);
const dkimTokens = createResponse.DkimAttributes?.Tokens || [];
await this.associateResourceWithTenant(domain, tenantName);
const verificationRecords = this.buildVerificationRecords(
domain,
dkimTokens,
);
return {
isVerified: false,
verificationRecords,
};
}
private async associateResourceWithTenant(
domain: string,
tenantName: string,
): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
await sesClient.send(
new CreateTenantResourceAssociationCommand({
TenantName: tenantName,
ResourceArn: `arn:aws:ses:${this.config.region}:${this.config.accountId}:identity/${domain}`,
}),
);
this.logger.log(`Associated domain ${domain} with tenant ${tenantName}`);
} catch (error) {
if (error.name === 'AlreadyExistsException') {
this.logger.log(
`Domain ${domain} already associated with tenant ${tenantName}`,
);
return;
}
throw error;
}
}
private async enableDkimSigning(domain: string): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
const dkimCommand = new PutEmailIdentityDkimAttributesCommand({
EmailIdentity: domain,
SigningEnabled: true,
});
await sesClient.send(dkimCommand);
this.logger.log(`Enabled DKIM signing for domain: ${domain}`);
}
private buildVerificationRecords(
domain: string,
dkimTokens: string[],
): VerificationRecord[] {
return dkimTokens.map((token) => ({
type: 'CNAME' as const,
key: `${token}._domainkey.${domain}`,
value: `${token}.dkim.amazonses.com`,
}));
}
private determineVerificationStatus(identityResponse: {
VerifiedForSendingStatus?: boolean;
DkimAttributes?: {
SigningEnabled?: boolean;
Status?: string;
};
}): EmailingDomainStatus {
const isVerified = identityResponse.VerifiedForSendingStatus === true;
const isDkimEnabled =
identityResponse.DkimAttributes?.SigningEnabled === true;
const dkimStatus = identityResponse.DkimAttributes?.Status;
if (isVerified && isDkimEnabled && dkimStatus === 'SUCCESS') {
return EmailingDomainStatus.VERIFIED;
}
if (
identityResponse.VerifiedForSendingStatus === false ||
dkimStatus === 'FAILED'
) {
return EmailingDomainStatus.FAILED;
}
return EmailingDomainStatus.PENDING;
}
}

View file

@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { type AwsSesError } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/types/aws-ses-error.type';
import {
EmailingDomainDriverException,
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
@Injectable()
export class AwsSesHandleErrorService {
public handleAwsSesError(error: AwsSesError, context?: string): never {
const name = error?.name ?? 'UnknownError';
const message = error?.message ?? 'No message';
const httpStatus = error?.$metadata?.httpStatusCode;
const suffix = context ? ` (${context})` : '';
if (this.isTemporary(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES temporary error${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.TEMPORARY_ERROR,
);
}
if (this.isInsufficientPermissions(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES insufficient permissions${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
if (this.isConfigurationError(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES configuration error${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
);
}
throw new EmailingDomainDriverException(
`AWS SES error${suffix}: ${name} - ${message}`,
EmailingDomainDriverExceptionCode.UNKNOWN,
);
}
private isTemporary(name: string, httpStatus?: number): boolean {
if (httpStatus && httpStatus >= 500) {
return true;
}
if (
name === 'ThrottlingException' ||
name === 'ServiceUnavailable' ||
name === 'InternalFailure' ||
name === 'RequestTimeout' ||
name === 'TooManyRequestsException'
) {
return true;
}
return false;
}
private isInsufficientPermissions(
name: string,
httpStatus?: number,
): boolean {
if (httpStatus === 403) {
return true;
}
if (
name === 'AccessDeniedException' ||
name === 'AccountSuspendedException'
) {
return true;
}
return false;
}
private isConfigurationError(name: string, httpStatus?: number): boolean {
if (httpStatus === 400) {
return true;
}
if (
name === 'InvalidParameterValue' ||
name === 'InvalidParameterCombination' ||
name === 'MissingParameter' ||
name === 'MessageRejected' ||
name === 'MailFromDomainNotVerifiedException' ||
name === 'FromEmailAddressNotVerifiedException'
) {
return true;
}
return false;
}
}

View file

@ -0,0 +1,8 @@
export type AwsSesError = {
name?: string;
message?: string;
$metadata?: {
httpStatusCode?: number;
requestId?: string;
};
};

View file

@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
import { type EmailingDomainDriverInterface } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesDriver } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class EmailingDomainDriverFactory extends DriverFactoryBase<EmailingDomainDriverInterface> {
constructor(
twentyConfigService: TwentyConfigService,
private readonly awsSesClientProvider: AwsSesClientProvider,
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
) {
super(twentyConfigService);
}
protected buildConfigKey(): string {
const driver = EmailingDomainDriver.AWS_SES;
if (driver === EmailingDomainDriver.AWS_SES) {
const awsConfigHash = this.getConfigGroupHash(
ConfigVariablesGroup.AwsSesSettings,
);
return `aws-ses|${awsConfigHash}`;
}
throw new Error(`Unsupported emailing domain driver: ${driver}`);
}
protected createDriver(): EmailingDomainDriverInterface {
const driver = EmailingDomainDriver.AWS_SES;
switch (driver) {
case EmailingDomainDriver.AWS_SES: {
const region = this.twentyConfigService.get('AWS_SES_REGION');
const accountId = this.twentyConfigService.get('AWS_SES_ACCOUNT_ID');
const accessKeyId = this.twentyConfigService.get(
'AWS_SES_ACCESS_KEY_ID',
);
const secretAccessKey = this.twentyConfigService.get(
'AWS_SES_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get(
'AWS_SES_SESSION_TOKEN',
);
const awsConfig: AwsSesDriverConfig = {
driver: EmailingDomainDriver.AWS_SES,
region,
accountId,
accessKeyId,
secretAccessKey,
sessionToken,
};
return new AwsSesDriver(
awsConfig,
this.awsSesClientProvider,
this.awsSesHandleErrorService,
);
}
default:
throw new Error(`Invalid emailing domain driver: ${driver}`);
}
}
}

View file

@ -0,0 +1,11 @@
import { CustomException } from 'src/utils/custom-exception';
export class EmailingDomainDriverException extends CustomException<EmailingDomainDriverExceptionCode> {}
export enum EmailingDomainDriverExceptionCode {
NOT_FOUND = 'NOT_FOUND',
TEMPORARY_ERROR = 'TEMPORARY_ERROR',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONFIGURATION_ERROR = 'CONFIGURATION_ERROR',
UNKNOWN = 'UNKNOWN',
}

View file

@ -0,0 +1,14 @@
import { type EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
export interface BaseDriverConfig {
driver: EmailingDomainDriver;
}
export interface AwsSesDriverConfig extends BaseDriverConfig {
driver: EmailingDomainDriver.AWS_SES;
region: string;
accountId: string;
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
}

View file

@ -0,0 +1,27 @@
import { type EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
export type DomainVerificationInput = {
domain: string;
workspaceId: string;
};
export type DomainStatusInput = {
domain: string;
workspaceId: string;
};
export type EmailingDomainVerificationResult = {
status: EmailingDomainStatus;
verificationRecords: VerificationRecord[];
verifiedAt: Date | null;
};
export interface EmailingDomainDriverInterface {
verifyDomain(
input: DomainVerificationInput,
): Promise<EmailingDomainVerificationResult>;
getDomainStatus(
input: DomainStatusInput,
): Promise<EmailingDomainVerificationResult>;
}

View file

@ -0,0 +1,10 @@
export enum EmailingDomainDriver {
AWS_SES = 'AWS_SES',
}
export enum EmailingDomainStatus {
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
FAILED = 'FAILED',
TEMPORARY_FAILURE = 'TEMPORARY_FAILURE',
}

View file

@ -0,0 +1,6 @@
export type VerificationRecord = {
type: 'TXT' | 'CNAME' | 'MX';
key: string;
value: string;
priority?: number;
};

View file

@ -0,0 +1,45 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
registerEnumType(EmailingDomainDriver, {
name: 'EmailingDomainDriver',
});
registerEnumType(EmailingDomainStatus, {
name: 'EmailingDomainStatus',
});
@ObjectType('EmailingDomain')
export class EmailingDomainDto {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
@Field(() => String)
domain: string;
@Field(() => EmailingDomainDriver)
driver: EmailingDomainDriver;
@Field(() => EmailingDomainStatus)
status: EmailingDomainStatus;
@Field(() => [VerificationRecord], { nullable: true })
verificationRecords: VerificationRecord[] | null;
@Field(() => Date, { nullable: true })
verifiedAt: Date | null;
}

View file

@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class VerificationRecord {
@Field(() => String)
type: 'TXT' | 'CNAME' | 'MX';
@Field(() => String)
key: string;
@Field(() => String)
value: string;
@Field(() => Number, { nullable: true })
priority?: number;
}

View file

@ -0,0 +1,71 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity({ name: 'emailingDomain', schema: 'core' })
@ObjectType()
@Unique('IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE', [
'domain',
'workspaceId',
])
export class EmailingDomain {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'varchar', nullable: false })
domain: string;
@Column({
type: 'enum',
enum: Object.values(EmailingDomainDriver),
nullable: false,
})
driver: EmailingDomainDriver;
@Column({
type: 'enum',
enum: Object.values(EmailingDomainStatus),
default: EmailingDomainStatus.PENDING,
nullable: false,
})
status: EmailingDomainStatus;
@Column({ type: 'jsonb', nullable: true })
verificationRecords: VerificationRecord[];
@Column({ type: 'timestamptz', nullable: true })
verifiedAt: Date | null;
@Column({ nullable: false })
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.emailingDomains, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}

View file

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
@Module({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature([EmailingDomain]),
],
exports: [EmailingDomainService],
providers: [
EmailingDomainService,
EmailingDomainResolver,
EmailingDomainDriverFactory,
AwsSesClientProvider,
AwsSesHandleErrorService,
],
})
export class EmailingDomainModule {}

View file

@ -0,0 +1,67 @@
import { UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { EmailingDomainDto } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@Resolver(() => EmailingDomainDto)
export class EmailingDomainResolver {
constructor(private readonly emailingDomainService: EmailingDomainService) {}
@Mutation(() => EmailingDomainDto)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('driver') driver: EmailingDomainDriver,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto> {
const emailingDomain =
await this.emailingDomainService.createEmailingDomain(
domain,
driver,
currentWorkspace,
);
return emailingDomain;
}
@Mutation(() => Boolean)
async deleteEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<boolean> {
await this.emailingDomainService.deleteEmailingDomain(currentWorkspace, id);
return true;
}
@Mutation(() => EmailingDomainDto)
async verifyEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto> {
const emailingDomain =
await this.emailingDomainService.verifyEmailingDomain(
currentWorkspace,
id,
);
return emailingDomain;
}
@Query(() => [EmailingDomainDto])
async getEmailingDomains(
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto[]> {
const emailingDomains =
await this.emailingDomainService.getEmailingDomains(currentWorkspace);
return emailingDomains;
}
}

View file

@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class EmailingDomainService {
constructor(
@InjectRepository(EmailingDomain)
private readonly emailingDomainRepository: Repository<EmailingDomain>,
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
) {}
async createEmailingDomain(
domain: string,
driver: EmailingDomainDriver,
workspace: Workspace,
): Promise<EmailingDomain> {
const existingDomain = await this.emailingDomainRepository.findOneBy({
domain,
workspaceId: workspace.id,
});
if (existingDomain) {
throw new Error('Emailing domain already exists for this workspace');
}
const driverInstance = this.emailingDomainDriverFactory.getCurrentDriver();
const verificationResult = await driverInstance.verifyDomain({
domain,
workspaceId: workspace.id,
});
const domainToCreate = {
domain,
driver,
workspaceId: workspace.id,
...verificationResult,
};
const savedDomain =
await this.emailingDomainRepository.save(domainToCreate);
return savedDomain;
}
async deleteEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<void> {
const emailingDomain = await this.emailingDomainRepository.findOneBy({
id: emailingDomainId,
workspaceId: workspace.id,
});
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
await this.emailingDomainRepository.delete({
id: emailingDomain.id,
});
}
async getEmailingDomains(workspace: Workspace): Promise<EmailingDomain[]> {
return await this.emailingDomainRepository.find({
where: {
workspaceId: workspace.id,
},
order: {
createdAt: 'DESC',
},
});
}
async getEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain | null> {
return await this.emailingDomainRepository.findOneBy({
id: emailingDomainId,
workspaceId: workspace.id,
});
}
async verifyEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain> {
const emailingDomain = await this.getEmailingDomain(
workspace,
emailingDomainId,
);
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
if (emailingDomain.status === EmailingDomainStatus.VERIFIED) {
throw new Error('Emailing domain is already verified');
}
const driver = this.emailingDomainDriverFactory.getCurrentDriver();
const verificationResult = await driver.verifyDomain({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
const updatedDomain = await this.emailingDomainRepository.save({
...emailingDomain,
...verificationResult,
});
return updatedDomain;
}
async syncEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain> {
const emailingDomain = await this.getEmailingDomain(
workspace,
emailingDomainId,
);
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
await this.emailingDomainRepository.update(
{
id: emailingDomainId,
},
{
verificationRecords: emailingDomain.verificationRecords,
status: EmailingDomainStatus.PENDING,
},
);
try {
const driver = this.emailingDomainDriverFactory.getCurrentDriver();
const statusResult = await driver.getDomainStatus({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
const updatedDomain = await this.emailingDomainRepository.save({
...emailingDomain,
...statusResult,
});
return updatedDomain;
} catch (error) {
await this.emailingDomainRepository.update(
{ id: emailingDomainId },
{
verificationRecords: emailingDomain.verificationRecords,
status: emailingDomain.status,
},
);
throw error;
}
}
}

View file

@ -18,5 +18,6 @@ export enum FeatureFlagKey {
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
}

View file

@ -1211,6 +1211,50 @@ export class ConfigVariables {
})
@ValidateIf((env) => env.IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED)
GOOGLE_MAP_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
description: 'AWS region',
type: ConfigVariableType.STRING,
})
@IsAWSRegion()
@IsOptional()
AWS_SES_REGION: AwsRegion;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS access key ID',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_ACCESS_KEY_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS session token',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_SESSION_TOKEN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS secret access key',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_SECRET_ACCESS_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
description: 'AWS Account ID for SES ARN construction',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_ACCOUNT_ID: string;
}
export const validate = (config: Record<string, unknown>): ConfigVariables => {

View file

@ -125,4 +125,9 @@ export const CONFIG_VARIABLES_GROUP_METADATA: Record<
'These have been set to sensible default so you probably dont need to change them unless you have a specific use-case.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.AwsSesSettings]: {
position: 2100,
description: 'Configure AWS SES settings for emailing domains',
isHiddenOnLoad: true,
},
};

View file

@ -19,4 +19,5 @@ export enum ConfigVariablesGroup {
AnalyticsConfig = 'audit-config',
TokensDuration = 'tokens-duration',
TwoFactorAuthentication = 'two-factor-authentication',
AwsSesSettings = 'aws-ses-settings',
}

View file

@ -19,9 +19,11 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ViewFieldDTO } from 'src/engine/core-modules/view/dtos/view-field.dto';
@ -41,7 +43,6 @@ import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-hand
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
registerEnumType(WorkspaceActivationStatus, {
name: 'WorkspaceActivationStatus',
@ -116,6 +117,9 @@ export class Workspace {
)
approvedAccessDomains: Relation<ApprovedAccessDomain[]>;
@OneToMany(() => EmailingDomain, (emailingDomain) => emailingDomain.workspace)
emailingDomains: Relation<EmailingDomain[]>;
@OneToMany(() => PublicDomain, (publicDomain) => publicDomain.workspace)
publicDomains: Relation<PublicDomain[]>;

View file

@ -141,6 +141,7 @@ describe('WorkspaceEntityManager', () => {
IS_CALENDAR_VIEW_ENABLED: false,
IS_GROUP_BY_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
},
eventEmitterService: {

View file

@ -83,7 +83,12 @@ export const seedFeatureFlags = async (
{
key: FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
workspaceId: workspaceId,
value: false,
value: true,
},
{
key: FeatureFlagKey.IS_EMAILING_DOMAIN_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_DYNAMIC_SEARCH_FIELDS_ENABLED,

View file

@ -25,6 +25,8 @@ export enum SettingsPath {
Domain = 'domains/domain',
PublicDomain = 'domains/public-domain',
NewApprovedAccessDomain = 'domains/approved-access-domain/new',
NewEmailingDomain = 'domains/emailing-domain/new',
EmailingDomainDetail = 'domains/emailing-domain/:domainId',
Releases = 'releases',
AI = 'ai',
AINewAgent = 'ai/new-agent',

946
yarn.lock

File diff suppressed because it is too large Load diff